Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7ade45097 | |||
| 7b159a3486 | |||
| 9470c7911d | |||
| 3d7727c304 | |||
| ff5b51072f | |||
| 633cbe696e | |||
| 0247ab45c7 | |||
| 0d932239d2 | |||
| 38b5462b09 | |||
| 9d7f132f6d | |||
| 3574a94e38 | |||
| be92d93f3f |
58
changelog.md
58
changelog.md
@@ -1,5 +1,63 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-11-27 - 1.6.0 - feat(ui.dashboard)
|
||||
Add Resource Usage card to dashboard and make dashboard cards full-height; add VSCode launch/tasks/config
|
||||
|
||||
- Introduce ResourceUsageCardComponent and include it as a full-width row in the dashboard layout.
|
||||
- Make several dashboard card components (Certificates, Traffic, Platform Services) full-height by adding host classes and applying h-full to ui-card elements for consistent card sizing.
|
||||
- Reflow dashboard rows (insert Resource Usage as a dedicated row and update row numbering) to improve visual layout.
|
||||
- Add VSCode workspace configuration: recommended Angular extension, launch configurations for ng serve/ng test, and npm tasks to run/start the UI in development.
|
||||
|
||||
## 2025-11-27 - 1.5.0 - feat(network)
|
||||
Add traffic stats endpoint and dashboard UI; enhance platform services and certificate health reporting
|
||||
|
||||
- Add /api/network/traffic-stats GET endpoint to the HTTP API with an optional minutes query parameter (validated, 1-60).
|
||||
- Implement traffic statistics aggregation in CaddyLogReceiver using rolling per-minute buckets (requestCount, errorCount, avgResponseTime, totalBytes, statusCounts, requestsPerMinute, errorRate).
|
||||
- Expose getTrafficStats(minutes?) in the Angular ApiService and add ITrafficStats type to the client API types.
|
||||
- Add dashboard UI components: TrafficCard, PlatformServicesCard, CertificatesCard and integrate them into the main Dashboard (including links to Platform Services).
|
||||
- Enhance system status data: platformServices entries now include displayName and resourceCount; add certificateHealth summary (valid, expiringSoon, expired, expiringDomains) returned by Onebox status.
|
||||
- Platform services manager and Onebox code updated to surface provider information and resource counts for the UI.
|
||||
- Add VSCode workspace launch/tasks recommendations for the UI development environment.
|
||||
|
||||
## 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)
|
||||
Add platform service log streaming, improve health checks and provisioning robustness
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/onebox",
|
||||
"version": "1.1.0",
|
||||
"version": "1.6.0",
|
||||
"exports": "./mod.ts",
|
||||
"nodeModulesDir": "auto",
|
||||
"tasks": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/onebox",
|
||||
"version": "1.1.0",
|
||||
"version": "1.6.0",
|
||||
"description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers",
|
||||
"main": "mod.ts",
|
||||
"type": "module",
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/onebox',
|
||||
version: '1.1.0',
|
||||
version: '1.6.0',
|
||||
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
|
||||
}
|
||||
|
||||
@@ -79,8 +79,84 @@ export class CaddyLogReceiver {
|
||||
private recentLogs: ICaddyAccessLog[] = [];
|
||||
private maxRecentLogs = 100;
|
||||
|
||||
// Traffic stats aggregation (hourly rolling window)
|
||||
private trafficStats: {
|
||||
timestamp: number;
|
||||
requestCount: number;
|
||||
errorCount: number; // 4xx + 5xx
|
||||
totalDuration: number; // microseconds
|
||||
totalSize: number; // bytes
|
||||
statusCounts: Record<string, number>; // "2xx", "3xx", "4xx", "5xx"
|
||||
}[] = [];
|
||||
private maxStatsAge = 3600 * 1000; // 1 hour in ms
|
||||
private statsInterval = 60 * 1000; // 1 minute buckets
|
||||
|
||||
constructor(port = 9999) {
|
||||
this.port = port;
|
||||
// Initialize first stats bucket
|
||||
this.trafficStats.push(this.createStatsBucket());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new stats bucket
|
||||
*/
|
||||
private createStatsBucket(): typeof this.trafficStats[0] {
|
||||
return {
|
||||
timestamp: Math.floor(Date.now() / this.statsInterval) * this.statsInterval,
|
||||
requestCount: 0,
|
||||
errorCount: 0,
|
||||
totalDuration: 0,
|
||||
totalSize: 0,
|
||||
statusCounts: { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current stats bucket, creating new one if needed
|
||||
*/
|
||||
private getCurrentStatsBucket(): typeof this.trafficStats[0] {
|
||||
const now = Date.now();
|
||||
const currentBucketTime = Math.floor(now / this.statsInterval) * this.statsInterval;
|
||||
|
||||
// Get or create current bucket
|
||||
let bucket = this.trafficStats[this.trafficStats.length - 1];
|
||||
if (!bucket || bucket.timestamp !== currentBucketTime) {
|
||||
bucket = this.createStatsBucket();
|
||||
this.trafficStats.push(bucket);
|
||||
|
||||
// Clean up old buckets
|
||||
const cutoff = now - this.maxStatsAge;
|
||||
while (this.trafficStats.length > 0 && this.trafficStats[0].timestamp < cutoff) {
|
||||
this.trafficStats.shift();
|
||||
}
|
||||
}
|
||||
|
||||
return bucket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a request in traffic stats
|
||||
*/
|
||||
private recordTrafficStats(log: ICaddyAccessLog): void {
|
||||
const bucket = this.getCurrentStatsBucket();
|
||||
|
||||
bucket.requestCount++;
|
||||
bucket.totalDuration += log.duration;
|
||||
bucket.totalSize += log.size || 0;
|
||||
|
||||
// Categorize status code
|
||||
const statusCategory = Math.floor(log.status / 100);
|
||||
if (statusCategory === 2) {
|
||||
bucket.statusCounts['2xx']++;
|
||||
} else if (statusCategory === 3) {
|
||||
bucket.statusCounts['3xx']++;
|
||||
} else if (statusCategory === 4) {
|
||||
bucket.statusCounts['4xx']++;
|
||||
bucket.errorCount++;
|
||||
} else if (statusCategory === 5) {
|
||||
bucket.statusCounts['5xx']++;
|
||||
bucket.errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,6 +257,9 @@ export class CaddyLogReceiver {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always record traffic stats (before sampling) for accurate aggregation
|
||||
this.recordTrafficStats(log);
|
||||
|
||||
// Update adaptive sampling
|
||||
this.updateSampling();
|
||||
|
||||
@@ -414,4 +493,57 @@ export class CaddyLogReceiver {
|
||||
recentLogsCount: this.recentLogs.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated traffic stats for the specified time range
|
||||
* @param minutes Number of minutes to aggregate (default: 60)
|
||||
*/
|
||||
getTrafficStats(minutes = 60): {
|
||||
requestCount: number;
|
||||
errorCount: number;
|
||||
avgResponseTime: number; // in milliseconds
|
||||
totalBytes: number;
|
||||
statusCounts: Record<string, number>;
|
||||
requestsPerMinute: number;
|
||||
errorRate: number; // percentage
|
||||
} {
|
||||
const now = Date.now();
|
||||
const cutoff = now - (minutes * 60 * 1000);
|
||||
|
||||
// Aggregate all buckets within the time range
|
||||
let requestCount = 0;
|
||||
let errorCount = 0;
|
||||
let totalDuration = 0;
|
||||
let totalBytes = 0;
|
||||
const statusCounts: Record<string, number> = { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 };
|
||||
|
||||
for (const bucket of this.trafficStats) {
|
||||
if (bucket.timestamp >= cutoff) {
|
||||
requestCount += bucket.requestCount;
|
||||
errorCount += bucket.errorCount;
|
||||
totalDuration += bucket.totalDuration;
|
||||
totalBytes += bucket.totalSize;
|
||||
for (const [status, count] of Object.entries(bucket.statusCounts)) {
|
||||
statusCounts[status] = (statusCounts[status] || 0) + count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate averages
|
||||
const avgResponseTime = requestCount > 0
|
||||
? (totalDuration / requestCount) / 1000 // Convert from microseconds to milliseconds
|
||||
: 0;
|
||||
const requestsPerMinute = requestCount / Math.max(minutes, 1);
|
||||
const errorRate = requestCount > 0 ? (errorCount / requestCount) * 100 : 0;
|
||||
|
||||
return {
|
||||
requestCount,
|
||||
errorCount,
|
||||
avgResponseTime: Math.round(avgResponseTime * 100) / 100, // Round to 2 decimal places
|
||||
totalBytes,
|
||||
statusCounts,
|
||||
requestsPerMinute: Math.round(requestsPerMinute * 100) / 100,
|
||||
errorRate: Math.round(errorRate * 100) / 100,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,16 +297,16 @@ export class OneboxHttpServer {
|
||||
// Platform Services endpoints
|
||||
} else if (path === '/api/platform-services' && method === 'GET') {
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
return await this.handleGetPlatformServiceStatsRequest(type);
|
||||
} else if (path.match(/^\/api\/services\/[^/]+\/platform-resources$/) && method === 'GET') {
|
||||
@@ -317,6 +317,8 @@ export class OneboxHttpServer {
|
||||
return await this.handleGetNetworkTargetsRequest();
|
||||
} else if (path === '/api/network/stats' && method === 'GET') {
|
||||
return await this.handleGetNetworkStatsRequest();
|
||||
} else if (path === '/api/network/traffic-stats' && method === 'GET') {
|
||||
return await this.handleGetTrafficStatsRequest(new URL(req.url));
|
||||
} else {
|
||||
return this.jsonResponse({ success: false, error: 'Not found' }, 404);
|
||||
}
|
||||
@@ -1323,6 +1325,7 @@ export class OneboxHttpServer {
|
||||
postgresql: 5432,
|
||||
rabbitmq: 5672,
|
||||
caddy: 80,
|
||||
clickhouse: 8123,
|
||||
};
|
||||
return ports[type] || 0;
|
||||
}
|
||||
@@ -1364,6 +1367,37 @@ export class OneboxHttpServer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get traffic stats from Caddy access logs
|
||||
*/
|
||||
private async handleGetTrafficStatsRequest(url: URL): Promise<Response> {
|
||||
try {
|
||||
// Get minutes parameter (default: 60)
|
||||
const minutesParam = url.searchParams.get('minutes');
|
||||
const minutes = minutesParam ? parseInt(minutesParam, 10) : 60;
|
||||
|
||||
if (isNaN(minutes) || minutes < 1 || minutes > 60) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Invalid minutes parameter. Must be between 1 and 60.',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const trafficStats = this.oneboxRef.caddyLogReceiver.getTrafficStats(minutes);
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
data: trafficStats,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get traffic stats: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to get traffic stats',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast message to all connected WebSocket clients
|
||||
*/
|
||||
|
||||
@@ -219,12 +219,51 @@ export class Onebox {
|
||||
const runningServices = services.filter((s) => s.status === 'running').length;
|
||||
const totalServices = services.length;
|
||||
|
||||
// Get platform services status
|
||||
// Get platform services status with resource counts
|
||||
const platformServices = this.platformServices.getAllPlatformServices();
|
||||
const platformServicesStatus = platformServices.map((ps) => ({
|
||||
type: ps.type,
|
||||
status: ps.status,
|
||||
}));
|
||||
const providers = this.platformServices.getAllProviders();
|
||||
const platformServicesStatus = providers.map((provider) => {
|
||||
const service = platformServices.find((s) => s.type === provider.type);
|
||||
// For Caddy, check actual runtime status since it starts without a DB record
|
||||
let status = service?.status || 'not-deployed';
|
||||
if (provider.type === 'caddy') {
|
||||
status = proxyStatus.http.running ? 'running' : 'stopped';
|
||||
}
|
||||
// Count resources for this platform service
|
||||
const resourceCount = service?.id
|
||||
? this.database.getPlatformResourcesByPlatformService(service.id).length
|
||||
: 0;
|
||||
return {
|
||||
type: provider.type,
|
||||
displayName: provider.displayName,
|
||||
status,
|
||||
resourceCount,
|
||||
};
|
||||
});
|
||||
|
||||
// Get certificate health summary
|
||||
const certificates = this.ssl.listCertificates();
|
||||
const now = Date.now();
|
||||
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
|
||||
let validCount = 0;
|
||||
let expiringCount = 0;
|
||||
let expiredCount = 0;
|
||||
const expiringDomains: { domain: string; daysRemaining: number }[] = [];
|
||||
|
||||
for (const cert of certificates) {
|
||||
if (cert.expiryDate <= now) {
|
||||
expiredCount++;
|
||||
} else if (cert.expiryDate <= now + thirtyDaysMs) {
|
||||
expiringCount++;
|
||||
const daysRemaining = Math.floor((cert.expiryDate - now) / (24 * 60 * 60 * 1000));
|
||||
expiringDomains.push({ domain: cert.domain, daysRemaining });
|
||||
} else {
|
||||
validCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort expiring domains by days remaining (ascending)
|
||||
expiringDomains.sort((a, b) => a.daysRemaining - b.daysRemaining);
|
||||
|
||||
return {
|
||||
docker: {
|
||||
@@ -245,6 +284,12 @@ export class Onebox {
|
||||
stopped: totalServices - runningServices,
|
||||
},
|
||||
platformServices: platformServicesStatus,
|
||||
certificateHealth: {
|
||||
valid: validCount,
|
||||
expiringSoon: expiringCount,
|
||||
expired: expiredCount,
|
||||
expiringDomains: expiringDomains.slice(0, 5), // Top 5 expiring
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get system status: ${getErrorMessage(error)}`);
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { IPlatformServiceProvider } from './providers/base.ts';
|
||||
import { MongoDBProvider } from './providers/mongodb.ts';
|
||||
import { MinioProvider } from './providers/minio.ts';
|
||||
import { CaddyProvider } from './providers/caddy.ts';
|
||||
import { ClickHouseProvider } from './providers/clickhouse.ts';
|
||||
import { logger } from '../../logging.ts';
|
||||
import { getErrorMessage } from '../../utils/error.ts';
|
||||
import { credentialEncryption } from '../encryption.ts';
|
||||
@@ -39,6 +40,7 @@ export class PlatformServicesManager {
|
||||
this.registerProvider(new MongoDBProvider(this.oneboxRef));
|
||||
this.registerProvider(new MinioProvider(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`);
|
||||
}
|
||||
@@ -275,6 +277,33 @@ export class PlatformServicesManager {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
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> {
|
||||
const config = this.getDefaultConfig();
|
||||
const containerName = this.getContainerName();
|
||||
|
||||
// Generate admin credentials
|
||||
const adminUser = 'admin';
|
||||
const adminPassword = credentialEncryption.generatePassword(32);
|
||||
|
||||
const adminCredentials = {
|
||||
username: adminUser,
|
||||
password: adminPassword,
|
||||
};
|
||||
const dataDir = '/var/lib/onebox/minio';
|
||||
|
||||
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
|
||||
try {
|
||||
await Deno.mkdir('/var/lib/onebox/minio', { recursive: true });
|
||||
await Deno.mkdir(dataDir, { recursive: true });
|
||||
} catch (e) {
|
||||
// Directory might already exist
|
||||
if (!(e instanceof Deno.errors.AlreadyExists)) {
|
||||
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
|
||||
});
|
||||
|
||||
// Store encrypted admin credentials
|
||||
// Store encrypted admin credentials (only update if new or changed)
|
||||
const encryptedCreds = await credentialEncryption.encrypt(adminCredentials);
|
||||
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||
if (platformService) {
|
||||
this.oneboxRef.database.updatePlatformService(platformService.id!, {
|
||||
containerId,
|
||||
@@ -118,41 +150,58 @@ export class MinioProvider extends BasePlatformServiceProvider {
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
logger.info('MinIO health check: starting...');
|
||||
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;
|
||||
}
|
||||
|
||||
// Get container IP for health check (hostname won't resolve from host)
|
||||
const containerIP = await this.oneboxRef.docker.getContainerIP(platformService.containerId);
|
||||
if (!containerIP) {
|
||||
logger.debug('MinIO health check: could not get container IP');
|
||||
logger.info(`MinIO 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
|
||||
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;
|
||||
}
|
||||
|
||||
const endpoint = `http://${containerIP}:9000/minio/health/live`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
logger.debug(`MinIO health check failed: ${getErrorMessage(error)}`);
|
||||
logger.info(`MinIO 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) {
|
||||
if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) {
|
||||
throw new Error('MinIO 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, 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 accessKey = credentialEncryption.generateAccessKey(20);
|
||||
@@ -160,14 +209,15 @@ export class MinioProvider extends BasePlatformServiceProvider {
|
||||
|
||||
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
|
||||
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({
|
||||
endpoint,
|
||||
endpoint: provisioningEndpoint,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: adminCreds.username,
|
||||
@@ -225,8 +275,11 @@ export class MinioProvider extends BasePlatformServiceProvider {
|
||||
// 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)
|
||||
const serviceEndpoint = `http://${containerName}:9000`;
|
||||
|
||||
const credentials: Record<string, string> = {
|
||||
endpoint,
|
||||
endpoint: serviceEndpoint,
|
||||
bucket: bucketName,
|
||||
accessKey: adminCreds.username, // Using root for now
|
||||
secretKey: adminCreds.password,
|
||||
@@ -253,20 +306,24 @@ export class MinioProvider extends BasePlatformServiceProvider {
|
||||
|
||||
async deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void> {
|
||||
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');
|
||||
}
|
||||
|
||||
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}'...`);
|
||||
|
||||
const { S3Client, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = await import('npm:@aws-sdk/client-s3@3');
|
||||
|
||||
const s3Client = new S3Client({
|
||||
endpoint,
|
||||
endpoint: `http://127.0.0.1:${hostPort}`,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: adminCreds.username,
|
||||
|
||||
@@ -49,10 +49,11 @@ export class OneboxServicesManager {
|
||||
|
||||
// Build platform requirements
|
||||
const platformRequirements: IPlatformRequirements | undefined =
|
||||
(options.enableMongoDB || options.enableS3)
|
||||
(options.enableMongoDB || options.enableS3 || options.enableClickHouse)
|
||||
? {
|
||||
mongodb: options.enableMongoDB,
|
||||
s3: options.enableS3,
|
||||
clickhouse: options.enableClickHouse,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ export interface ITokenCreatedResponse {
|
||||
}
|
||||
|
||||
// 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 TPlatformServiceStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
|
||||
|
||||
@@ -110,6 +110,7 @@ export interface IPlatformResource {
|
||||
export interface IPlatformRequirements {
|
||||
mongodb?: boolean;
|
||||
s3?: boolean;
|
||||
clickhouse?: boolean;
|
||||
}
|
||||
|
||||
export interface IProvisionedResource {
|
||||
@@ -287,6 +288,7 @@ export interface IServiceDeployOptions {
|
||||
// Platform service requirements
|
||||
enableMongoDB?: boolean;
|
||||
enableS3?: boolean;
|
||||
enableClickHouse?: boolean;
|
||||
}
|
||||
|
||||
// HTTP API request/response types
|
||||
|
||||
@@ -32,10 +32,8 @@ export const routes: Routes = [
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./features/services/services-list.component').then(
|
||||
(m) => m.ServicesListComponent
|
||||
),
|
||||
redirectTo: 'user',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'create',
|
||||
@@ -52,12 +50,19 @@ export const routes: Routes = [
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ':name',
|
||||
path: 'detail/:name',
|
||||
loadComponent: () =>
|
||||
import('./features/services/service-detail.component').then(
|
||||
(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: [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./features/network/network.component').then(
|
||||
(m) => m.NetworkComponent
|
||||
),
|
||||
redirectTo: 'proxy',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'domains/:domain',
|
||||
@@ -77,15 +80,32 @@ export const routes: Routes = [
|
||||
(m) => m.DomainDetailComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ':tab',
|
||||
loadComponent: () =>
|
||||
import('./features/network/network.component').then(
|
||||
(m) => m.NetworkComponent
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'registries',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'onebox',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: ':tab',
|
||||
loadComponent: () =>
|
||||
import('./features/registries/registries.component').then(
|
||||
(m) => m.RegistriesComponent
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'tokens',
|
||||
loadComponent: () =>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
INetworkStats,
|
||||
IContainerStats,
|
||||
IMetric,
|
||||
ITrafficStats,
|
||||
} from '../types/api.types';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -204,4 +205,9 @@ export class ApiService {
|
||||
async getNetworkStats(): Promise<IApiResponse<INetworkStats>> {
|
||||
return firstValueFrom(this.http.get<IApiResponse<INetworkStats>>('/api/network/stats'));
|
||||
}
|
||||
|
||||
async getTrafficStats(minutes?: number): Promise<IApiResponse<ITrafficStats>> {
|
||||
const params = minutes ? `?minutes=${minutes}` : '';
|
||||
return firstValueFrom(this.http.get<IApiResponse<ITrafficStats>>(`/api/network/traffic-stats${params}`));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,13 +16,14 @@ export interface ILoginResponse {
|
||||
}
|
||||
|
||||
// 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 TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
|
||||
|
||||
export interface IPlatformRequirements {
|
||||
mongodb?: boolean;
|
||||
s3?: boolean;
|
||||
clickhouse?: boolean;
|
||||
}
|
||||
|
||||
export interface IService {
|
||||
@@ -56,6 +57,7 @@ export interface IServiceCreate {
|
||||
autoUpdateOnPush?: boolean;
|
||||
enableMongoDB?: boolean;
|
||||
enableS3?: boolean;
|
||||
enableClickHouse?: boolean; // ClickHouse analytics database
|
||||
}
|
||||
|
||||
export interface IServiceUpdate {
|
||||
@@ -79,7 +81,18 @@ export interface ISystemStatus {
|
||||
dns: { configured: boolean };
|
||||
ssl: { configured: boolean; certificateCount: number };
|
||||
services: { total: number; running: number; stopped: number };
|
||||
platformServices: Array<{ type: TPlatformServiceType; status: TPlatformServiceStatus }>;
|
||||
platformServices: Array<{
|
||||
type: TPlatformServiceType;
|
||||
displayName: string;
|
||||
status: TPlatformServiceStatus;
|
||||
resourceCount: number;
|
||||
}>;
|
||||
certificateHealth: {
|
||||
valid: number;
|
||||
expiringSoon: number;
|
||||
expired: number;
|
||||
expiringDomains: Array<{ domain: string; daysRemaining: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IDomain {
|
||||
@@ -320,3 +333,14 @@ export interface IStatsUpdateMessage {
|
||||
stats: IContainerStats;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// Traffic stats from Caddy access logs
|
||||
export interface ITrafficStats {
|
||||
requestCount: number;
|
||||
errorCount: number;
|
||||
avgResponseTime: number; // milliseconds
|
||||
totalBytes: number;
|
||||
statusCounts: Record<string, number>; // '2xx', '3xx', '4xx', '5xx'
|
||||
requestsPerMinute: number;
|
||||
errorRate: number; // percentage
|
||||
}
|
||||
|
||||
99
ui/src/app/features/dashboard/certificates-card.component.ts
Normal file
99
ui/src/app/features/dashboard/certificates-card.component.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
|
||||
interface ICertificateHealth {
|
||||
valid: number;
|
||||
expiringSoon: number;
|
||||
expired: number;
|
||||
expiringDomains: Array<{ domain: string; daysRemaining: number }>;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-certificates-card',
|
||||
standalone: true,
|
||||
host: { class: 'block h-full' },
|
||||
imports: [
|
||||
RouterLink,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
],
|
||||
template: `
|
||||
<ui-card class="h-full">
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Certificates</ui-card-title>
|
||||
<ui-card-description>SSL/TLS certificate status</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-3">
|
||||
<!-- Status summary -->
|
||||
<div class="space-y-2">
|
||||
@if (health.valid > 0) {
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-4 w-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span class="text-sm">{{ health.valid }} valid</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (health.expiringSoon > 0) {
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-4 w-4 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span class="text-sm text-warning">{{ health.expiringSoon }} expiring soon</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (health.expired > 0) {
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-4 w-4 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span class="text-sm text-destructive">{{ health.expired }} expired</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (health.valid === 0 && health.expiringSoon === 0 && health.expired === 0) {
|
||||
<div class="text-sm text-muted-foreground">No certificates</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Expiring domains list -->
|
||||
@if (health.expiringDomains.length > 0) {
|
||||
<div class="border-t pt-2 space-y-1">
|
||||
@for (item of health.expiringDomains; track item.domain) {
|
||||
<a [routerLink]="['/network']"
|
||||
class="flex items-center justify-between text-sm py-1 hover:bg-muted/50 rounded px-1 -mx-1 transition-colors">
|
||||
<span class="truncate text-muted-foreground">{{ item.domain }}</span>
|
||||
<span
|
||||
class="ml-2 whitespace-nowrap"
|
||||
[class.text-warning]="item.daysRemaining > 7"
|
||||
[class.text-destructive]="item.daysRemaining <= 7">
|
||||
{{ item.daysRemaining }}d
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
`,
|
||||
})
|
||||
export class CertificatesCardComponent {
|
||||
@Input() health: ICertificateHealth = {
|
||||
valid: 0,
|
||||
expiringSoon: 0,
|
||||
expired: 0,
|
||||
expiringDomains: [],
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
import { ButtonComponent } from '../../ui/button/button.component';
|
||||
import { BadgeComponent } from '../../ui/badge/badge.component';
|
||||
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
import { TrafficCardComponent } from './traffic-card.component';
|
||||
import { PlatformServicesCardComponent } from './platform-services-card.component';
|
||||
import { CertificatesCardComponent } from './certificates-card.component';
|
||||
import { ResourceUsageCardComponent } from './resource-usage-card.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
@@ -28,6 +32,10 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
ButtonComponent,
|
||||
BadgeComponent,
|
||||
SkeletonComponent,
|
||||
TrafficCardComponent,
|
||||
PlatformServicesCardComponent,
|
||||
CertificatesCardComponent,
|
||||
ResourceUsageCardComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
@@ -63,7 +71,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
}
|
||||
</div>
|
||||
} @else if (status()) {
|
||||
<!-- Stats Grid -->
|
||||
<!-- Row 1: Key Stats Grid -->
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
@@ -117,8 +125,23 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
</ui-card>
|
||||
</div>
|
||||
|
||||
<!-- System Status -->
|
||||
<!-- Row 2: Resource Usage (full width) -->
|
||||
<app-resource-usage-card />
|
||||
|
||||
<!-- Row 3: Traffic & Platform Services (2-column) -->
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<!-- Traffic Overview -->
|
||||
<app-traffic-card />
|
||||
|
||||
<!-- Platform Services Status -->
|
||||
<app-platform-services-card [services]="status()!.platformServices" />
|
||||
</div>
|
||||
|
||||
<!-- Row 4: Certificates & System Status (3-column) -->
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Certificates Health -->
|
||||
<app-certificates-card [health]="status()!.certificateHealth" />
|
||||
|
||||
<!-- Reverse Proxy -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
@@ -139,56 +162,42 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
</ui-badge>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">Certificates</span>
|
||||
<span class="text-sm font-medium">{{ status()!.reverseProxy.https.certificates }}</span>
|
||||
<span class="text-sm">Routes</span>
|
||||
<span class="text-sm font-medium">{{ status()!.reverseProxy.routes }}</span>
|
||||
</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- DNS -->
|
||||
<!-- DNS & SSL Combined -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>DNS</ui-card-title>
|
||||
<ui-card-description>DNS configuration status</ui-card-description>
|
||||
<ui-card-title>DNS & SSL</ui-card-title>
|
||||
<ui-card-description>Configuration status</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<ui-card-content class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">Cloudflare</span>
|
||||
<span class="text-sm">Cloudflare DNS</span>
|
||||
<ui-badge [variant]="status()!.dns.configured ? 'success' : 'secondary'">
|
||||
{{ status()!.dns.configured ? 'Configured' : 'Not configured' }}
|
||||
</ui-badge>
|
||||
</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- SSL -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>SSL/TLS</ui-card-title>
|
||||
<ui-card-description>Certificate management</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">ACME</span>
|
||||
<span class="text-sm">ACME (Let's Encrypt)</span>
|
||||
<ui-badge [variant]="status()!.ssl.configured ? 'success' : 'secondary'">
|
||||
{{ status()!.ssl.configured ? 'Configured' : 'Not configured' }}
|
||||
</ui-badge>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">Certificates</span>
|
||||
<span class="text-sm font-medium">{{ status()!.ssl.certificateCount }} managed</span>
|
||||
</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<!-- Row 5: Quick Actions -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Quick Actions</ui-card-title>
|
||||
<ui-card-description>Common tasks and shortcuts</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="flex gap-4">
|
||||
<ui-card-content class="flex flex-wrap gap-4">
|
||||
<a routerLink="/services/create">
|
||||
<button uiButton>
|
||||
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
@@ -200,6 +209,9 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
<a routerLink="/services">
|
||||
<button uiButton variant="outline">View All Services</button>
|
||||
</a>
|
||||
<a routerLink="/platform-services">
|
||||
<button uiButton variant="outline">Platform Services</button>
|
||||
</a>
|
||||
<a routerLink="/network">
|
||||
<button uiButton variant="outline">Manage Domains</button>
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TPlatformServiceType, TPlatformServiceStatus } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
|
||||
interface IPlatformServiceSummary {
|
||||
type: TPlatformServiceType;
|
||||
displayName: string;
|
||||
status: TPlatformServiceStatus;
|
||||
resourceCount: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-platform-services-card',
|
||||
standalone: true,
|
||||
host: { class: 'block h-full' },
|
||||
imports: [
|
||||
RouterLink,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
],
|
||||
template: `
|
||||
<ui-card class="h-full">
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Platform Services</ui-card-title>
|
||||
<ui-card-description>Infrastructure status</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-2">
|
||||
@for (service of services; track service.type) {
|
||||
<a [routerLink]="['/platform-services', service.type]"
|
||||
class="flex items-center justify-between py-1 hover:bg-muted/50 rounded px-1 -mx-1 transition-colors">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Status indicator -->
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
[class.bg-success]="service.status === 'running'"
|
||||
[class.bg-muted-foreground]="service.status === 'not-deployed' || service.status === 'stopped'"
|
||||
[class.bg-warning]="service.status === 'starting' || service.status === 'stopping'"
|
||||
[class.bg-destructive]="service.status === 'failed'">
|
||||
</span>
|
||||
<span class="text-sm">{{ service.displayName }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
@if (service.status === 'running') {
|
||||
@if (service.resourceCount > 0) {
|
||||
<span>{{ service.resourceCount }} {{ service.resourceCount === 1 ? getResourceLabel(service.type) : getResourceLabelPlural(service.type) }}</span>
|
||||
} @else {
|
||||
<span>Running</span>
|
||||
}
|
||||
} @else {
|
||||
<span class="capitalize">{{ formatStatus(service.status) }}</span>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
} @empty {
|
||||
<div class="text-sm text-muted-foreground">No platform services</div>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
`,
|
||||
})
|
||||
export class PlatformServicesCardComponent {
|
||||
@Input() services: IPlatformServiceSummary[] = [];
|
||||
|
||||
formatStatus(status: string): string {
|
||||
return status.replace('-', ' ');
|
||||
}
|
||||
|
||||
getResourceLabel(type: TPlatformServiceType): string {
|
||||
switch (type) {
|
||||
case 'mongodb':
|
||||
case 'postgresql':
|
||||
case 'clickhouse':
|
||||
return 'DB';
|
||||
case 'minio':
|
||||
return 'bucket';
|
||||
case 'redis':
|
||||
return 'cache';
|
||||
case 'rabbitmq':
|
||||
return 'queue';
|
||||
default:
|
||||
return 'resource';
|
||||
}
|
||||
}
|
||||
|
||||
getResourceLabelPlural(type: TPlatformServiceType): string {
|
||||
switch (type) {
|
||||
case 'mongodb':
|
||||
case 'postgresql':
|
||||
case 'clickhouse':
|
||||
return 'DBs';
|
||||
case 'minio':
|
||||
return 'buckets';
|
||||
case 'redis':
|
||||
return 'caches';
|
||||
case 'rabbitmq':
|
||||
return 'queues';
|
||||
default:
|
||||
return 'resources';
|
||||
}
|
||||
}
|
||||
}
|
||||
271
ui/src/app/features/dashboard/resource-usage-card.component.ts
Normal file
271
ui/src/app/features/dashboard/resource-usage-card.component.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { Component, inject, signal, effect, OnDestroy } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { WebSocketService } from '../../core/services/websocket.service';
|
||||
import { IContainerStats } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
|
||||
interface IServiceStats {
|
||||
name: string;
|
||||
stats: IContainerStats;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface IAggregatedStats {
|
||||
totalCpuPercent: number;
|
||||
totalMemoryUsed: number;
|
||||
totalMemoryLimit: number;
|
||||
memoryPercent: number;
|
||||
networkRxRate: number;
|
||||
networkTxRate: number;
|
||||
serviceCount: number;
|
||||
topCpuServices: { name: string; value: number }[];
|
||||
topMemoryServices: { name: string; value: number }[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-resource-usage-card',
|
||||
standalone: true,
|
||||
host: { class: 'block' },
|
||||
imports: [
|
||||
RouterLink,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
],
|
||||
template: `
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div>
|
||||
<ui-card-title>Resource Usage</ui-card-title>
|
||||
<ui-card-description>Aggregated across {{ aggregated().serviceCount }} services</ui-card-description>
|
||||
</div>
|
||||
<a routerLink="/services" class="text-xs text-muted-foreground hover:text-primary transition-colors">
|
||||
View All
|
||||
</a>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-4">
|
||||
@if (aggregated().serviceCount === 0) {
|
||||
<div class="text-sm text-muted-foreground">No running services</div>
|
||||
} @else {
|
||||
<!-- CPU Usage -->
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">CPU</span>
|
||||
<span class="font-medium" [class.text-warning]="aggregated().totalCpuPercent > 70" [class.text-destructive]="aggregated().totalCpuPercent > 90">
|
||||
{{ aggregated().totalCpuPercent.toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
class="h-full transition-all duration-300"
|
||||
[class.bg-success]="aggregated().totalCpuPercent <= 70"
|
||||
[class.bg-warning]="aggregated().totalCpuPercent > 70 && aggregated().totalCpuPercent <= 90"
|
||||
[class.bg-destructive]="aggregated().totalCpuPercent > 90"
|
||||
[style.width.%]="Math.min(aggregated().totalCpuPercent, 100)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Memory Usage -->
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">Memory</span>
|
||||
<span class="font-medium" [class.text-warning]="aggregated().memoryPercent > 70" [class.text-destructive]="aggregated().memoryPercent > 90">
|
||||
{{ formatBytes(aggregated().totalMemoryUsed) }} / {{ formatBytes(aggregated().totalMemoryLimit) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
class="h-full transition-all duration-300"
|
||||
[class.bg-success]="aggregated().memoryPercent <= 70"
|
||||
[class.bg-warning]="aggregated().memoryPercent > 70 && aggregated().memoryPercent <= 90"
|
||||
[class.bg-destructive]="aggregated().memoryPercent > 90"
|
||||
[style.width.%]="Math.min(aggregated().memoryPercent, 100)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network -->
|
||||
<div class="flex items-center justify-between text-sm pt-1 border-t">
|
||||
<span class="text-muted-foreground">Network</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-3 w-3 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
{{ formatBytesRate(aggregated().networkRxRate) }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-3 w-3 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
{{ formatBytesRate(aggregated().networkTxRate) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Consumers -->
|
||||
@if (aggregated().topCpuServices.length > 0 || aggregated().topMemoryServices.length > 0) {
|
||||
<div class="pt-2 border-t">
|
||||
<div class="text-xs text-muted-foreground mb-1">Top consumers</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
@for (svc of aggregated().topCpuServices.slice(0, 2); track svc.name) {
|
||||
<span>
|
||||
<span class="text-muted-foreground">{{ svc.name }}:</span>
|
||||
<span class="font-medium"> {{ svc.value.toFixed(1) }}% CPU</span>
|
||||
</span>
|
||||
}
|
||||
@for (svc of aggregated().topMemoryServices.slice(0, 2); track svc.name) {
|
||||
<span>
|
||||
<span class="text-muted-foreground">{{ svc.name }}:</span>
|
||||
<span class="font-medium"> {{ formatBytes(svc.value) }}</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
`,
|
||||
})
|
||||
export class ResourceUsageCardComponent implements OnDestroy {
|
||||
private ws = inject(WebSocketService);
|
||||
|
||||
// Store stats per service
|
||||
private serviceStats = new Map<string, IServiceStats>();
|
||||
private cleanupInterval: any;
|
||||
|
||||
// Expose Math for template
|
||||
Math = Math;
|
||||
|
||||
aggregated = signal<IAggregatedStats>({
|
||||
totalCpuPercent: 0,
|
||||
totalMemoryUsed: 0,
|
||||
totalMemoryLimit: 0,
|
||||
memoryPercent: 0,
|
||||
networkRxRate: 0,
|
||||
networkTxRate: 0,
|
||||
serviceCount: 0,
|
||||
topCpuServices: [],
|
||||
topMemoryServices: [],
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Listen for stats updates
|
||||
effect(() => {
|
||||
const update = this.ws.statsUpdate();
|
||||
if (update) {
|
||||
this.serviceStats.set(update.serviceName, {
|
||||
name: update.serviceName,
|
||||
stats: update.stats,
|
||||
timestamp: update.timestamp,
|
||||
});
|
||||
this.recalculateAggregated();
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up stale entries every 30 seconds
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const staleThreshold = 60000; // 60 seconds
|
||||
let changed = false;
|
||||
|
||||
for (const [name, entry] of this.serviceStats.entries()) {
|
||||
if (now - entry.timestamp > staleThreshold) {
|
||||
this.serviceStats.delete(name);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
this.recalculateAggregated();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
}
|
||||
|
||||
private recalculateAggregated(): void {
|
||||
const entries = Array.from(this.serviceStats.values());
|
||||
|
||||
if (entries.length === 0) {
|
||||
this.aggregated.set({
|
||||
totalCpuPercent: 0,
|
||||
totalMemoryUsed: 0,
|
||||
totalMemoryLimit: 0,
|
||||
memoryPercent: 0,
|
||||
networkRxRate: 0,
|
||||
networkTxRate: 0,
|
||||
serviceCount: 0,
|
||||
topCpuServices: [],
|
||||
topMemoryServices: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let totalCpu = 0;
|
||||
let totalMemUsed = 0;
|
||||
let totalMemLimit = 0;
|
||||
let totalNetRx = 0;
|
||||
let totalNetTx = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
totalCpu += entry.stats.cpuPercent;
|
||||
totalMemUsed += entry.stats.memoryUsed;
|
||||
totalMemLimit += entry.stats.memoryLimit;
|
||||
totalNetRx += entry.stats.networkRx;
|
||||
totalNetTx += entry.stats.networkTx;
|
||||
}
|
||||
|
||||
// Sort by CPU usage for top consumers
|
||||
const sortedByCpu = [...entries]
|
||||
.filter(e => e.stats.cpuPercent > 0)
|
||||
.sort((a, b) => b.stats.cpuPercent - a.stats.cpuPercent)
|
||||
.slice(0, 3)
|
||||
.map(e => ({ name: e.name, value: e.stats.cpuPercent }));
|
||||
|
||||
// Sort by memory usage for top consumers
|
||||
const sortedByMem = [...entries]
|
||||
.filter(e => e.stats.memoryUsed > 0)
|
||||
.sort((a, b) => b.stats.memoryUsed - a.stats.memoryUsed)
|
||||
.slice(0, 3)
|
||||
.map(e => ({ name: e.name, value: e.stats.memoryUsed }));
|
||||
|
||||
this.aggregated.set({
|
||||
totalCpuPercent: totalCpu,
|
||||
totalMemoryUsed: totalMemUsed,
|
||||
totalMemoryLimit: totalMemLimit,
|
||||
memoryPercent: totalMemLimit > 0 ? (totalMemUsed / totalMemLimit) * 100 : 0,
|
||||
networkRxRate: totalNetRx,
|
||||
networkTxRate: totalNetTx,
|
||||
serviceCount: entries.length,
|
||||
topCpuServices: sortedByCpu,
|
||||
topMemoryServices: sortedByMem,
|
||||
});
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
formatBytesRate(bytes: number): string {
|
||||
return this.formatBytes(bytes) + '/s';
|
||||
}
|
||||
}
|
||||
163
ui/src/app/features/dashboard/traffic-card.component.ts
Normal file
163
ui/src/app/features/dashboard/traffic-card.component.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Component, inject, signal, OnInit, OnDestroy } from '@angular/core';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { ITrafficStats } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-traffic-card',
|
||||
standalone: true,
|
||||
host: { class: 'block h-full' },
|
||||
imports: [
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
SkeletonComponent,
|
||||
],
|
||||
template: `
|
||||
<ui-card class="h-full">
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Traffic (Last Hour)</ui-card-title>
|
||||
<ui-card-description>Request metrics from access logs</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="space-y-3">
|
||||
@if (loading() && !stats()) {
|
||||
<ui-skeleton class="h-4 w-32" />
|
||||
<ui-skeleton class="h-4 w-24" />
|
||||
<ui-skeleton class="h-4 w-28" />
|
||||
} @else if (stats()) {
|
||||
<div class="space-y-2">
|
||||
<!-- Request count -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">Requests</span>
|
||||
<span class="text-sm font-medium">{{ formatNumber(stats()!.requestCount) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Error rate -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">Errors</span>
|
||||
<span class="text-sm font-medium" [class.text-destructive]="stats()!.errorRate > 5">
|
||||
{{ stats()!.errorCount }} ({{ stats()!.errorRate }}%)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Avg response time -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">Avg Response</span>
|
||||
<span class="text-sm font-medium" [class.text-warning]="stats()!.avgResponseTime > 500">
|
||||
{{ stats()!.avgResponseTime }}ms
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Requests per minute -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">Req/min</span>
|
||||
<span class="text-sm font-medium">{{ stats()!.requestsPerMinute }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Status code distribution -->
|
||||
<div class="pt-2 border-t">
|
||||
<div class="flex gap-1 h-2 rounded overflow-hidden bg-muted">
|
||||
@if (getStatusPercent('2xx') > 0) {
|
||||
<div
|
||||
class="bg-success transition-all"
|
||||
[style.width.%]="getStatusPercent('2xx')"
|
||||
[title]="'2xx: ' + stats()!.statusCounts['2xx']">
|
||||
</div>
|
||||
}
|
||||
@if (getStatusPercent('3xx') > 0) {
|
||||
<div
|
||||
class="bg-blue-500 transition-all"
|
||||
[style.width.%]="getStatusPercent('3xx')"
|
||||
[title]="'3xx: ' + stats()!.statusCounts['3xx']">
|
||||
</div>
|
||||
}
|
||||
@if (getStatusPercent('4xx') > 0) {
|
||||
<div
|
||||
class="bg-warning transition-all"
|
||||
[style.width.%]="getStatusPercent('4xx')"
|
||||
[title]="'4xx: ' + stats()!.statusCounts['4xx']">
|
||||
</div>
|
||||
}
|
||||
@if (getStatusPercent('5xx') > 0) {
|
||||
<div
|
||||
class="bg-destructive transition-all"
|
||||
[style.width.%]="getStatusPercent('5xx')"
|
||||
[title]="'5xx: ' + stats()!.statusCounts['5xx']">
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex justify-between mt-1 text-xs text-muted-foreground">
|
||||
<span>2xx</span>
|
||||
<span>3xx</span>
|
||||
<span>4xx</span>
|
||||
<span>5xx</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="text-sm text-muted-foreground">No traffic data available</div>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
`,
|
||||
})
|
||||
export class TrafficCardComponent implements OnInit, OnDestroy {
|
||||
private api = inject(ApiService);
|
||||
|
||||
stats = signal<ITrafficStats | null>(null);
|
||||
loading = signal(false);
|
||||
|
||||
private refreshInterval: any;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadStats();
|
||||
// Refresh every 30 seconds
|
||||
this.refreshInterval = setInterval(() => this.loadStats(), 30000);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
}
|
||||
}
|
||||
|
||||
async loadStats(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.api.getTrafficStats(60);
|
||||
if (response.success && response.data) {
|
||||
this.stats.set(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load traffic stats:', err);
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
getStatusPercent(status: string): number {
|
||||
const s = this.stats();
|
||||
if (!s || s.requestCount === 0) return 0;
|
||||
const count = s.statusCounts[status] || 0;
|
||||
return (count / s.requestCount) * 100;
|
||||
}
|
||||
}
|
||||
@@ -191,7 +191,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
</ui-badge>
|
||||
</ui-table-cell>
|
||||
<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>
|
||||
</a>
|
||||
</ui-table-cell>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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 { ToastService } from '../../core/services/toast.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 {
|
||||
private api = inject(ApiService);
|
||||
private toast = inject(ToastService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private routeSub?: Subscription;
|
||||
networkLogStream = inject(NetworkLogStreamService);
|
||||
|
||||
@ViewChild('logContainer') logContainer!: ElementRef<HTMLDivElement>;
|
||||
@@ -321,15 +326,24 @@ export class NetworkComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.routeSub?.unsubscribe();
|
||||
this.networkLogStream.disconnect();
|
||||
}
|
||||
|
||||
setTab(tab: TNetworkTab): void {
|
||||
this.activeTab.set(tab);
|
||||
this.router.navigate(['/network', tab]);
|
||||
}
|
||||
|
||||
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 { RouterLink } from '@angular/router';
|
||||
import { RouterLink, ActivatedRoute, Router } from '@angular/router';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { IRegistry, IRegistryCreate } from '../../core/types/api.types';
|
||||
@@ -242,9 +243,12 @@ type TRegistriesTab = 'onebox' | 'external';
|
||||
</ui-dialog>
|
||||
`,
|
||||
})
|
||||
export class RegistriesComponent implements OnInit {
|
||||
export class RegistriesComponent implements OnInit, OnDestroy {
|
||||
private api = inject(ApiService);
|
||||
private toast = inject(ToastService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private routeSub?: Subscription;
|
||||
|
||||
activeTab = signal<TRegistriesTab>('onebox');
|
||||
registries = signal<IRegistry[]>([]);
|
||||
@@ -256,13 +260,25 @@ export class RegistriesComponent implements OnInit {
|
||||
form: IRegistryCreate = { url: '', username: '', password: '' };
|
||||
|
||||
setTab(tab: TRegistriesTab): void {
|
||||
this.activeTab.set(tab);
|
||||
this.router.navigate(['/registries', tab]);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.routeSub?.unsubscribe();
|
||||
}
|
||||
|
||||
async loadRegistries(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { ApiService } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
@@ -22,7 +23,6 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
selector: 'app-platform-service-detail',
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterLink,
|
||||
FormsModule,
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
@@ -38,7 +38,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
@@ -235,6 +235,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
`,
|
||||
})
|
||||
export class PlatformServiceDetailComponent implements OnInit, OnDestroy {
|
||||
private location = inject(Location);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
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> {
|
||||
this.loading.set(true);
|
||||
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.',
|
||||
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.',
|
||||
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.';
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</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>
|
||||
|
||||
@if (form.enableMongoDB || form.enableS3) {
|
||||
@if (form.enableMongoDB || form.enableS3 || form.enableClickHouse) {
|
||||
<ui-alert variant="default">
|
||||
<ui-alert-description>
|
||||
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,
|
||||
enableMongoDB: false,
|
||||
enableS3: false,
|
||||
enableClickHouse: false,
|
||||
};
|
||||
|
||||
envVars = signal<EnvVar[]>([]);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 { FormsModule } from '@angular/forms';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
@@ -58,7 +59,7 @@ import {
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
@@ -422,6 +423,7 @@ import {
|
||||
`,
|
||||
})
|
||||
export class ServiceDetailComponent implements OnInit, OnDestroy {
|
||||
private location = inject(Location);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private api = inject(ApiService);
|
||||
@@ -479,6 +481,10 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
this.location.back();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.logStream.disconnect();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, inject, signal, effect, OnInit } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Component, inject, signal, effect, OnInit, OnDestroy } from '@angular/core';
|
||||
import { RouterLink, ActivatedRoute, Router } from '@angular/router';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { WebSocketService } from '../../core/services/websocket.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>
|
||||
</div>
|
||||
@if (activeTab() === 'user') {
|
||||
<a routerLink="/services/create">
|
||||
<a [routerLink]="['/services/create']">
|
||||
<button uiButton>
|
||||
<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" />
|
||||
@@ -105,7 +106,7 @@ type TServicesTab = 'user' | 'system';
|
||||
</svg>
|
||||
<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>
|
||||
<a routerLink="/services/create" class="mt-4 inline-block">
|
||||
<a [routerLink]="['/services/create']" class="mt-4 inline-block">
|
||||
<button uiButton>Deploy Service</button>
|
||||
</a>
|
||||
</div>
|
||||
@@ -124,7 +125,7 @@ type TServicesTab = 'user' | 'system';
|
||||
@for (service of services(); track service.name) {
|
||||
<ui-table-row>
|
||||
<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 }}
|
||||
</a>
|
||||
</ui-table-cell>
|
||||
@@ -299,10 +300,13 @@ type TServicesTab = 'user' | 'system';
|
||||
</ui-dialog>
|
||||
`,
|
||||
})
|
||||
export class ServicesListComponent implements OnInit {
|
||||
export class ServicesListComponent implements OnInit, OnDestroy {
|
||||
private api = inject(ApiService);
|
||||
private ws = inject(WebSocketService);
|
||||
private toast = inject(ToastService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private routeSub?: Subscription;
|
||||
|
||||
// Tab state
|
||||
activeTab = signal<TServicesTab>('user');
|
||||
@@ -331,12 +335,24 @@ export class ServicesListComponent implements OnInit {
|
||||
}
|
||||
|
||||
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.loadPlatformServices();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.routeSub?.unsubscribe();
|
||||
}
|
||||
|
||||
setTab(tab: TServicesTab): void {
|
||||
this.activeTab.set(tab);
|
||||
this.router.navigate(['/services', tab]);
|
||||
}
|
||||
|
||||
async loadServices(): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user