Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5d239ab28 | |||
| 5cd7e7c252 | |||
| e7ade45097 | |||
| 7b159a3486 | |||
| 9470c7911d | |||
| 3d7727c304 | |||
| ff5b51072f | |||
| 633cbe696e |
40
changelog.md
40
changelog.md
@@ -1,5 +1,45 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-11-27 - 1.7.0 - feat(backup)
|
||||||
|
Add backup system: BackupManager, DB schema, API endpoints and UI support
|
||||||
|
|
||||||
|
Introduce a complete service backup/restore subsystem with encrypted archives, database records and REST endpoints. Implements BackupManager with export/import for service config, platform resources (MongoDB, MinIO, ClickHouse), and Docker images; adds BackupRepository and migrations for backups table and include_image_in_backup; integrates backup flows into the HTTP API and the UI client; exposes backup password management and restore modes (restore/import/clone). Wire BackupManager into Onebox initialization.
|
||||||
|
|
||||||
|
- Add BackupManager implementing create/restore/export/import/encrypt/decrypt workflows (service config, platform resource dumps, Docker image export/import) and support for restore modes: restore, import, clone.
|
||||||
|
- Add BackupRepository and database migrations: create backups table and add include_image_in_backup column to services; database API methods for create/get/list/delete backups.
|
||||||
|
- Add HTTP API endpoints for backup management: list/create/get/download/delete backups, restore backups (/api/backups/restore) and backup password endpoints (/api/settings/backup-password).
|
||||||
|
- Update UI ApiService and types: add IBackup, IRestoreOptions, IRestoreResult, IBackupPasswordStatus and corresponding ApiService methods (getBackups, createBackup, getBackup, deleteBackup, getBackupDownloadUrl, restoreBackup, setBackupPassword, checkBackupPassword).
|
||||||
|
- Expose includeImageInBackup flag on service model and persist it in ServiceRepository (defaults to true for existing rows); service update flow supports toggling this option.
|
||||||
|
- Integrate BackupManager into Onebox core (initialized in Onebox constructor) and wire HTTP handlers to use the new manager; add DB repository export/import glue so backups are stored and referenced by ID.
|
||||||
|
|
||||||
|
## 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)
|
## 2025-11-26 - 1.3.0 - feat(platform-services)
|
||||||
Add ClickHouse platform service support (provider, types, provisioning, UI and port mappings)
|
Add ClickHouse platform service support (provider, types, provisioning, UI and port mappings)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/onebox",
|
"name": "@serve.zone/onebox",
|
||||||
"version": "1.3.0",
|
"version": "1.7.0",
|
||||||
"exports": "./mod.ts",
|
"exports": "./mod.ts",
|
||||||
"nodeModulesDir": "auto",
|
"nodeModulesDir": "auto",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/onebox",
|
"name": "@serve.zone/onebox",
|
||||||
"version": "1.3.0",
|
"version": "1.7.0",
|
||||||
"description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers",
|
"description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers",
|
||||||
"main": "mod.ts",
|
"main": "mod.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/onebox',
|
name: '@serve.zone/onebox',
|
||||||
version: '1.3.0',
|
version: '1.7.0',
|
||||||
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
|
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
|
||||||
}
|
}
|
||||||
|
|||||||
1112
ts/classes/backup-manager.ts
Normal file
1112
ts/classes/backup-manager.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -79,8 +79,84 @@ export class CaddyLogReceiver {
|
|||||||
private recentLogs: ICaddyAccessLog[] = [];
|
private recentLogs: ICaddyAccessLog[] = [];
|
||||||
private maxRecentLogs = 100;
|
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) {
|
constructor(port = 9999) {
|
||||||
this.port = port;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always record traffic stats (before sampling) for accurate aggregation
|
||||||
|
this.recordTrafficStats(log);
|
||||||
|
|
||||||
// Update adaptive sampling
|
// Update adaptive sampling
|
||||||
this.updateSampling();
|
this.updateSampling();
|
||||||
|
|
||||||
@@ -414,4 +493,57 @@ export class CaddyLogReceiver {
|
|||||||
recentLogsCount: this.recentLogs.length,
|
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
|
// Platform Services endpoints
|
||||||
} else if (path === '/api/platform-services' && method === 'GET') {
|
} else if (path === '/api/platform-services' && method === 'GET') {
|
||||||
return await this.handleListPlatformServicesRequest();
|
return await this.handleListPlatformServicesRequest();
|
||||||
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)$/) && method === 'GET') {
|
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)$/) && method === 'GET') {
|
||||||
const type = path.split('/').pop()! as TPlatformServiceType;
|
const type = path.split('/').pop()! as TPlatformServiceType;
|
||||||
return await this.handleGetPlatformServiceRequest(type);
|
return await this.handleGetPlatformServiceRequest(type);
|
||||||
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)\/start$/) && method === 'POST') {
|
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)\/start$/) && method === 'POST') {
|
||||||
const type = path.split('/')[3] as TPlatformServiceType;
|
const type = path.split('/')[3] as TPlatformServiceType;
|
||||||
return await this.handleStartPlatformServiceRequest(type);
|
return await this.handleStartPlatformServiceRequest(type);
|
||||||
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)\/stop$/) && method === 'POST') {
|
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)\/stop$/) && method === 'POST') {
|
||||||
const type = path.split('/')[3] as TPlatformServiceType;
|
const type = path.split('/')[3] as TPlatformServiceType;
|
||||||
return await this.handleStopPlatformServiceRequest(type);
|
return await this.handleStopPlatformServiceRequest(type);
|
||||||
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)\/stats$/) && method === 'GET') {
|
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)\/stats$/) && method === 'GET') {
|
||||||
const type = path.split('/')[3] as TPlatformServiceType;
|
const type = path.split('/')[3] as TPlatformServiceType;
|
||||||
return await this.handleGetPlatformServiceStatsRequest(type);
|
return await this.handleGetPlatformServiceStatsRequest(type);
|
||||||
} else if (path.match(/^\/api\/services\/[^/]+\/platform-resources$/) && method === 'GET') {
|
} else if (path.match(/^\/api\/services\/[^/]+\/platform-resources$/) && method === 'GET') {
|
||||||
@@ -317,6 +317,32 @@ export class OneboxHttpServer {
|
|||||||
return await this.handleGetNetworkTargetsRequest();
|
return await this.handleGetNetworkTargetsRequest();
|
||||||
} else if (path === '/api/network/stats' && method === 'GET') {
|
} else if (path === '/api/network/stats' && method === 'GET') {
|
||||||
return await this.handleGetNetworkStatsRequest();
|
return await this.handleGetNetworkStatsRequest();
|
||||||
|
} else if (path === '/api/network/traffic-stats' && method === 'GET') {
|
||||||
|
return await this.handleGetTrafficStatsRequest(new URL(req.url));
|
||||||
|
// Backup endpoints
|
||||||
|
} else if (path === '/api/backups' && method === 'GET') {
|
||||||
|
return await this.handleListBackupsRequest();
|
||||||
|
} else if (path.match(/^\/api\/services\/[^/]+\/backups$/) && method === 'GET') {
|
||||||
|
const serviceName = path.split('/')[3];
|
||||||
|
return await this.handleListServiceBackupsRequest(serviceName);
|
||||||
|
} else if (path.match(/^\/api\/services\/[^/]+\/backup$/) && method === 'POST') {
|
||||||
|
const serviceName = path.split('/')[3];
|
||||||
|
return await this.handleCreateBackupRequest(serviceName);
|
||||||
|
} else if (path.match(/^\/api\/backups\/\d+$/) && method === 'GET') {
|
||||||
|
const backupId = Number(path.split('/').pop());
|
||||||
|
return await this.handleGetBackupRequest(backupId);
|
||||||
|
} else if (path.match(/^\/api\/backups\/\d+\/download$/) && method === 'GET') {
|
||||||
|
const backupId = Number(path.split('/')[3]);
|
||||||
|
return await this.handleDownloadBackupRequest(backupId);
|
||||||
|
} else if (path.match(/^\/api\/backups\/\d+$/) && method === 'DELETE') {
|
||||||
|
const backupId = Number(path.split('/').pop());
|
||||||
|
return await this.handleDeleteBackupRequest(backupId);
|
||||||
|
} else if (path === '/api/backups/restore' && method === 'POST') {
|
||||||
|
return await this.handleRestoreBackupRequest(req);
|
||||||
|
} else if (path === '/api/settings/backup-password' && method === 'POST') {
|
||||||
|
return await this.handleSetBackupPasswordRequest(req);
|
||||||
|
} else if (path === '/api/settings/backup-password' && method === 'GET') {
|
||||||
|
return await this.handleCheckBackupPasswordRequest();
|
||||||
} else {
|
} else {
|
||||||
return this.jsonResponse({ success: false, error: 'Not found' }, 404);
|
return this.jsonResponse({ success: false, error: 'Not found' }, 404);
|
||||||
}
|
}
|
||||||
@@ -1365,6 +1391,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
|
* Broadcast message to all connected WebSocket clients
|
||||||
*/
|
*/
|
||||||
@@ -1984,6 +2041,276 @@ export class OneboxHttpServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Backup Endpoints ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all backups
|
||||||
|
*/
|
||||||
|
private async handleListBackupsRequest(): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const backups = this.oneboxRef.backupManager.listBackups();
|
||||||
|
return this.jsonResponse({ success: true, data: backups });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to list backups: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to list backups',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List backups for a specific service
|
||||||
|
*/
|
||||||
|
private async handleListServiceBackupsRequest(serviceName: string): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const service = this.oneboxRef.services.getService(serviceName);
|
||||||
|
if (!service) {
|
||||||
|
return this.jsonResponse({ success: false, error: 'Service not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const backups = this.oneboxRef.backupManager.listBackups(serviceName);
|
||||||
|
return this.jsonResponse({ success: true, data: backups });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to list backups for service ${serviceName}: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to list backups',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a backup for a service
|
||||||
|
*/
|
||||||
|
private async handleCreateBackupRequest(serviceName: string): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const service = this.oneboxRef.services.getService(serviceName);
|
||||||
|
if (!service) {
|
||||||
|
return this.jsonResponse({ success: false, error: 'Service not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.oneboxRef.backupManager.createBackup(serviceName);
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
message: `Backup created for service ${serviceName}`,
|
||||||
|
data: result.backup,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to create backup for service ${serviceName}: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to create backup',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific backup by ID
|
||||||
|
*/
|
||||||
|
private async handleGetBackupRequest(backupId: number): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const backup = this.oneboxRef.database.getBackupById(backupId);
|
||||||
|
if (!backup) {
|
||||||
|
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.jsonResponse({ success: true, data: backup });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to get backup ${backupId}: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to get backup',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a backup file
|
||||||
|
*/
|
||||||
|
private async handleDownloadBackupRequest(backupId: number): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const filePath = this.oneboxRef.backupManager.getBackupFilePath(backupId);
|
||||||
|
if (!filePath) {
|
||||||
|
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
try {
|
||||||
|
await Deno.stat(filePath);
|
||||||
|
} catch {
|
||||||
|
return this.jsonResponse({ success: false, error: 'Backup file not found on disk' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file and return as download
|
||||||
|
const backup = this.oneboxRef.database.getBackupById(backupId);
|
||||||
|
const file = await Deno.readFile(filePath);
|
||||||
|
|
||||||
|
return new Response(file, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Content-Disposition': `attachment; filename="${backup?.filename || 'backup.tar.enc'}"`,
|
||||||
|
'Content-Length': String(file.length),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to download backup ${backupId}: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to download backup',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a backup
|
||||||
|
*/
|
||||||
|
private async handleDeleteBackupRequest(backupId: number): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const backup = this.oneboxRef.database.getBackupById(backupId);
|
||||||
|
if (!backup) {
|
||||||
|
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.oneboxRef.backupManager.deleteBackup(backupId);
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
message: 'Backup deleted successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to delete backup ${backupId}: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to delete backup',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a backup
|
||||||
|
*/
|
||||||
|
private async handleRestoreBackupRequest(req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { backupId, mode, newServiceName, overwriteExisting, skipPlatformData } = body;
|
||||||
|
|
||||||
|
if (!backupId) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Backup ID is required',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mode || !['restore', 'import', 'clone'].includes(mode)) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Valid mode required: restore, import, or clone',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get backup file path
|
||||||
|
const filePath = this.oneboxRef.backupManager.getBackupFilePath(backupId);
|
||||||
|
if (!filePath) {
|
||||||
|
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate mode-specific requirements
|
||||||
|
if ((mode === 'import' || mode === 'clone') && !newServiceName) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: `New service name required for '${mode}' mode`,
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.oneboxRef.backupManager.restoreBackup(filePath, {
|
||||||
|
mode,
|
||||||
|
newServiceName,
|
||||||
|
overwriteExisting: overwriteExisting === true,
|
||||||
|
skipPlatformData: skipPlatformData === true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
message: `Backup restored successfully as service '${result.service.name}'`,
|
||||||
|
data: {
|
||||||
|
service: result.service,
|
||||||
|
platformResourcesRestored: result.platformResourcesRestored,
|
||||||
|
warnings: result.warnings,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to restore backup: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to restore backup',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set backup encryption password
|
||||||
|
*/
|
||||||
|
private async handleSetBackupPasswordRequest(req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { password } = body;
|
||||||
|
|
||||||
|
if (!password || typeof password !== 'string') {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Password is required',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Password must be at least 8 characters',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store password in settings
|
||||||
|
this.oneboxRef.database.setSetting('backup_encryption_password', password);
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
message: 'Backup password set successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to set backup password: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to set backup password',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if backup password is configured
|
||||||
|
*/
|
||||||
|
private async handleCheckBackupPasswordRequest(): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const password = this.oneboxRef.database.getSetting('backup_encryption_password');
|
||||||
|
const isConfigured = password !== null && password.length > 0;
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
isConfigured,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to check backup password: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to check backup password',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to create JSON response
|
* Helper to create JSON response
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { CertRequirementManager } from './cert-requirement-manager.ts';
|
|||||||
import { RegistryManager } from './registry.ts';
|
import { RegistryManager } from './registry.ts';
|
||||||
import { PlatformServicesManager } from './platform-services/index.ts';
|
import { PlatformServicesManager } from './platform-services/index.ts';
|
||||||
import { CaddyLogReceiver } from './caddy-log-receiver.ts';
|
import { CaddyLogReceiver } from './caddy-log-receiver.ts';
|
||||||
|
import { BackupManager } from './backup-manager.ts';
|
||||||
|
|
||||||
export class Onebox {
|
export class Onebox {
|
||||||
public database: OneboxDatabase;
|
public database: OneboxDatabase;
|
||||||
@@ -36,6 +37,7 @@ export class Onebox {
|
|||||||
public registry: RegistryManager;
|
public registry: RegistryManager;
|
||||||
public platformServices: PlatformServicesManager;
|
public platformServices: PlatformServicesManager;
|
||||||
public caddyLogReceiver: CaddyLogReceiver;
|
public caddyLogReceiver: CaddyLogReceiver;
|
||||||
|
public backupManager: BackupManager;
|
||||||
|
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
|
|
||||||
@@ -67,6 +69,9 @@ export class Onebox {
|
|||||||
|
|
||||||
// Initialize Caddy log receiver
|
// Initialize Caddy log receiver
|
||||||
this.caddyLogReceiver = new CaddyLogReceiver(9999);
|
this.caddyLogReceiver = new CaddyLogReceiver(9999);
|
||||||
|
|
||||||
|
// Initialize Backup manager
|
||||||
|
this.backupManager = new BackupManager(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -219,12 +224,51 @@ export class Onebox {
|
|||||||
const runningServices = services.filter((s) => s.status === 'running').length;
|
const runningServices = services.filter((s) => s.status === 'running').length;
|
||||||
const totalServices = services.length;
|
const totalServices = services.length;
|
||||||
|
|
||||||
// Get platform services status
|
// Get platform services status with resource counts
|
||||||
const platformServices = this.platformServices.getAllPlatformServices();
|
const platformServices = this.platformServices.getAllPlatformServices();
|
||||||
const platformServicesStatus = platformServices.map((ps) => ({
|
const providers = this.platformServices.getAllProviders();
|
||||||
type: ps.type,
|
const platformServicesStatus = providers.map((provider) => {
|
||||||
status: ps.status,
|
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 {
|
return {
|
||||||
docker: {
|
docker: {
|
||||||
@@ -245,6 +289,12 @@ export class Onebox {
|
|||||||
stopped: totalServices - runningServices,
|
stopped: totalServices - runningServices,
|
||||||
},
|
},
|
||||||
platformServices: platformServicesStatus,
|
platformServices: platformServicesStatus,
|
||||||
|
certificateHealth: {
|
||||||
|
valid: validCount,
|
||||||
|
expiringSoon: expiringCount,
|
||||||
|
expired: expiredCount,
|
||||||
|
expiringDomains: expiringDomains.slice(0, 5), // Top 5 expiring
|
||||||
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to get system status: ${getErrorMessage(error)}`);
|
logger.error(`Failed to get system status: ${getErrorMessage(error)}`);
|
||||||
|
|||||||
@@ -166,10 +166,10 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
|
|||||||
|
|
||||||
// Use docker exec to run health check inside the container
|
// Use docker exec to run health check inside the container
|
||||||
// This avoids network issues with overlay networks
|
// This avoids network issues with overlay networks
|
||||||
// Note: ClickHouse image has wget but not curl
|
// Note: ClickHouse image has wget but not curl - use full path for reliability
|
||||||
const result = await this.oneboxRef.docker.execInContainer(
|
const result = await this.oneboxRef.docker.execInContainer(
|
||||||
platformService.containerId,
|
platformService.containerId,
|
||||||
['wget', '-q', '-O', '-', 'http://localhost:8123/ping']
|
['/usr/bin/wget', '-q', '-O', '-', 'http://localhost:8123/ping']
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.exitCode === 0) {
|
if (result.exitCode === 0) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
IDomain,
|
IDomain,
|
||||||
ICertificate,
|
ICertificate,
|
||||||
ICertRequirement,
|
ICertRequirement,
|
||||||
|
IBackup,
|
||||||
} from '../types.ts';
|
} from '../types.ts';
|
||||||
import type { TBindValue } from './types.ts';
|
import type { TBindValue } from './types.ts';
|
||||||
import { logger } from '../logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
AuthRepository,
|
AuthRepository,
|
||||||
MetricsRepository,
|
MetricsRepository,
|
||||||
PlatformRepository,
|
PlatformRepository,
|
||||||
|
BackupRepository,
|
||||||
} from './repositories/index.ts';
|
} from './repositories/index.ts';
|
||||||
|
|
||||||
export class OneboxDatabase {
|
export class OneboxDatabase {
|
||||||
@@ -44,6 +46,7 @@ export class OneboxDatabase {
|
|||||||
private authRepo!: AuthRepository;
|
private authRepo!: AuthRepository;
|
||||||
private metricsRepo!: MetricsRepository;
|
private metricsRepo!: MetricsRepository;
|
||||||
private platformRepo!: PlatformRepository;
|
private platformRepo!: PlatformRepository;
|
||||||
|
private backupRepo!: BackupRepository;
|
||||||
|
|
||||||
constructor(dbPath = './.nogit/onebox.db') {
|
constructor(dbPath = './.nogit/onebox.db') {
|
||||||
this.dbPath = dbPath;
|
this.dbPath = dbPath;
|
||||||
@@ -76,6 +79,7 @@ export class OneboxDatabase {
|
|||||||
this.authRepo = new AuthRepository(queryFn);
|
this.authRepo = new AuthRepository(queryFn);
|
||||||
this.metricsRepo = new MetricsRepository(queryFn);
|
this.metricsRepo = new MetricsRepository(queryFn);
|
||||||
this.platformRepo = new PlatformRepository(queryFn);
|
this.platformRepo = new PlatformRepository(queryFn);
|
||||||
|
this.backupRepo = new BackupRepository(queryFn);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to initialize database: ${getErrorMessage(error)}`);
|
logger.error(`Failed to initialize database: ${getErrorMessage(error)}`);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -705,6 +709,37 @@ export class OneboxDatabase {
|
|||||||
this.setMigrationVersion(8);
|
this.setMigrationVersion(8);
|
||||||
logger.success('Migration 8 completed: Certificates table now stores PEM content');
|
logger.success('Migration 8 completed: Certificates table now stores PEM content');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration 9: Backup system tables
|
||||||
|
const version9 = this.getMigrationVersion();
|
||||||
|
if (version9 < 9) {
|
||||||
|
logger.info('Running migration 9: Creating backup system tables...');
|
||||||
|
|
||||||
|
// Add include_image_in_backup column to services table
|
||||||
|
this.query(`ALTER TABLE services ADD COLUMN include_image_in_backup INTEGER DEFAULT 1`);
|
||||||
|
|
||||||
|
// Create backups table
|
||||||
|
this.query(`
|
||||||
|
CREATE TABLE backups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
service_id INTEGER NOT NULL,
|
||||||
|
service_name TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
size_bytes INTEGER NOT NULL,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
includes_image INTEGER NOT NULL,
|
||||||
|
platform_resources TEXT NOT NULL DEFAULT '[]',
|
||||||
|
checksum TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_backups_service ON backups(service_id)');
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_backups_created ON backups(created_at DESC)');
|
||||||
|
|
||||||
|
this.setMigrationVersion(9);
|
||||||
|
logger.success('Migration 9 completed: Backup system tables created');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Migration failed: ${getErrorMessage(error)}`);
|
logger.error(`Migration failed: ${getErrorMessage(error)}`);
|
||||||
if (error instanceof Error && error.stack) {
|
if (error instanceof Error && error.stack) {
|
||||||
@@ -1078,4 +1113,30 @@ export class OneboxDatabase {
|
|||||||
deletePlatformResourcesByService(serviceId: number): void {
|
deletePlatformResourcesByService(serviceId: number): void {
|
||||||
this.platformRepo.deletePlatformResourcesByService(serviceId);
|
this.platformRepo.deletePlatformResourcesByService(serviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Backups (delegated to repository) ============
|
||||||
|
|
||||||
|
createBackup(backup: Omit<IBackup, 'id'>): IBackup {
|
||||||
|
return this.backupRepo.create(backup);
|
||||||
|
}
|
||||||
|
|
||||||
|
getBackupById(id: number): IBackup | null {
|
||||||
|
return this.backupRepo.getById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getBackupsByService(serviceId: number): IBackup[] {
|
||||||
|
return this.backupRepo.getByService(serviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllBackups(): IBackup[] {
|
||||||
|
return this.backupRepo.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteBackup(id: number): void {
|
||||||
|
this.backupRepo.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteBackupsByService(serviceId: number): void {
|
||||||
|
this.backupRepo.deleteByService(serviceId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
86
ts/database/repositories/backup.repository.ts
Normal file
86
ts/database/repositories/backup.repository.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Backup Repository
|
||||||
|
* Handles CRUD operations for backups table
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseRepository } from '../base.repository.ts';
|
||||||
|
import type { IBackup, TPlatformServiceType } from '../../types.ts';
|
||||||
|
|
||||||
|
export class BackupRepository extends BaseRepository {
|
||||||
|
create(backup: Omit<IBackup, 'id'>): IBackup {
|
||||||
|
this.query(
|
||||||
|
`INSERT INTO backups (
|
||||||
|
service_id, service_name, filename, size_bytes, created_at,
|
||||||
|
includes_image, platform_resources, checksum
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
backup.serviceId,
|
||||||
|
backup.serviceName,
|
||||||
|
backup.filename,
|
||||||
|
backup.sizeBytes,
|
||||||
|
backup.createdAt,
|
||||||
|
backup.includesImage ? 1 : 0,
|
||||||
|
JSON.stringify(backup.platformResources),
|
||||||
|
backup.checksum,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the created backup by looking for the most recent one with matching filename
|
||||||
|
const rows = this.query(
|
||||||
|
'SELECT * FROM backups WHERE filename = ? ORDER BY id DESC LIMIT 1',
|
||||||
|
[backup.filename]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.rowToBackup(rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: number): IBackup | null {
|
||||||
|
const rows = this.query('SELECT * FROM backups WHERE id = ?', [id]);
|
||||||
|
return rows.length > 0 ? this.rowToBackup(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getByService(serviceId: number): IBackup[] {
|
||||||
|
const rows = this.query(
|
||||||
|
'SELECT * FROM backups WHERE service_id = ? ORDER BY created_at DESC',
|
||||||
|
[serviceId]
|
||||||
|
);
|
||||||
|
return rows.map((row) => this.rowToBackup(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll(): IBackup[] {
|
||||||
|
const rows = this.query('SELECT * FROM backups ORDER BY created_at DESC');
|
||||||
|
return rows.map((row) => this.rowToBackup(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id: number): void {
|
||||||
|
this.query('DELETE FROM backups WHERE id = ?', [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteByService(serviceId: number): void {
|
||||||
|
this.query('DELETE FROM backups WHERE service_id = ?', [serviceId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private rowToBackup(row: any): IBackup {
|
||||||
|
let platformResources: TPlatformServiceType[] = [];
|
||||||
|
const platformResourcesRaw = row.platform_resources;
|
||||||
|
if (platformResourcesRaw) {
|
||||||
|
try {
|
||||||
|
platformResources = JSON.parse(String(platformResourcesRaw));
|
||||||
|
} catch {
|
||||||
|
platformResources = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
serviceId: Number(row.service_id),
|
||||||
|
serviceName: String(row.service_name),
|
||||||
|
filename: String(row.filename),
|
||||||
|
sizeBytes: Number(row.size_bytes),
|
||||||
|
createdAt: Number(row.created_at),
|
||||||
|
includesImage: Boolean(row.includes_image),
|
||||||
|
platformResources,
|
||||||
|
checksum: String(row.checksum),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,3 +8,4 @@ export { CertificateRepository } from './certificate.repository.ts';
|
|||||||
export { AuthRepository } from './auth.repository.ts';
|
export { AuthRepository } from './auth.repository.ts';
|
||||||
export { MetricsRepository } from './metrics.repository.ts';
|
export { MetricsRepository } from './metrics.repository.ts';
|
||||||
export { PlatformRepository } from './platform.repository.ts';
|
export { PlatformRepository } from './platform.repository.ts';
|
||||||
|
export { BackupRepository } from './backup.repository.ts';
|
||||||
|
|||||||
@@ -119,6 +119,10 @@ export class ServiceRepository extends BaseRepository {
|
|||||||
fields.push('platform_requirements = ?');
|
fields.push('platform_requirements = ?');
|
||||||
values.push(JSON.stringify(updates.platformRequirements));
|
values.push(JSON.stringify(updates.platformRequirements));
|
||||||
}
|
}
|
||||||
|
if (updates.includeImageInBackup !== undefined) {
|
||||||
|
fields.push('include_image_in_backup = ?');
|
||||||
|
values.push(updates.includeImageInBackup ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
fields.push('updated_at = ?');
|
fields.push('updated_at = ?');
|
||||||
values.push(Date.now());
|
values.push(Date.now());
|
||||||
@@ -172,6 +176,9 @@ export class ServiceRepository extends BaseRepository {
|
|||||||
autoUpdateOnPush: row.auto_update_on_push ? Boolean(row.auto_update_on_push) : undefined,
|
autoUpdateOnPush: row.auto_update_on_push ? Boolean(row.auto_update_on_push) : undefined,
|
||||||
imageDigest: row.image_digest ? String(row.image_digest) : undefined,
|
imageDigest: row.image_digest ? String(row.image_digest) : undefined,
|
||||||
platformRequirements,
|
platformRequirements,
|
||||||
|
includeImageInBackup: row.include_image_in_backup !== undefined
|
||||||
|
? Boolean(row.include_image_in_backup)
|
||||||
|
: true, // Default to true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
67
ts/types.ts
67
ts/types.ts
@@ -23,6 +23,8 @@ export interface IService {
|
|||||||
imageDigest?: string;
|
imageDigest?: string;
|
||||||
// Platform service requirements
|
// Platform service requirements
|
||||||
platformRequirements?: IPlatformRequirements;
|
platformRequirements?: IPlatformRequirements;
|
||||||
|
// Backup settings
|
||||||
|
includeImageInBackup?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registry types
|
// Registry types
|
||||||
@@ -317,3 +319,68 @@ export interface ICliArgs {
|
|||||||
_: string[];
|
_: string[];
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backup types
|
||||||
|
export type TBackupRestoreMode = 'restore' | 'import' | 'clone';
|
||||||
|
|
||||||
|
export interface IBackup {
|
||||||
|
id?: number;
|
||||||
|
serviceId: number;
|
||||||
|
serviceName: string; // Denormalized for display
|
||||||
|
filename: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
createdAt: number;
|
||||||
|
includesImage: boolean;
|
||||||
|
platformResources: TPlatformServiceType[]; // Which platform types were backed up
|
||||||
|
checksum: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBackupManifest {
|
||||||
|
version: string;
|
||||||
|
createdAt: number;
|
||||||
|
oneboxVersion: string;
|
||||||
|
serviceName: string;
|
||||||
|
includesImage: boolean;
|
||||||
|
platformResources: TPlatformServiceType[];
|
||||||
|
checksum: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBackupServiceConfig {
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
registry?: string;
|
||||||
|
envVars: Record<string, string>;
|
||||||
|
port: number;
|
||||||
|
domain?: string;
|
||||||
|
useOneboxRegistry?: boolean;
|
||||||
|
registryRepository?: string;
|
||||||
|
registryImageTag?: string;
|
||||||
|
autoUpdateOnPush?: boolean;
|
||||||
|
platformRequirements?: IPlatformRequirements;
|
||||||
|
includeImageInBackup?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBackupPlatformResource {
|
||||||
|
resourceType: TPlatformResourceType;
|
||||||
|
resourceName: string;
|
||||||
|
platformServiceType: TPlatformServiceType;
|
||||||
|
credentials: Record<string, string>; // Decrypted for backup, re-encrypted on restore
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBackupResult {
|
||||||
|
backup: IBackup;
|
||||||
|
filePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRestoreOptions {
|
||||||
|
mode: TBackupRestoreMode;
|
||||||
|
newServiceName?: string; // Required for 'import' and 'clone' modes
|
||||||
|
skipPlatformData?: boolean; // Restore config only, skip DB/bucket data
|
||||||
|
overwriteExisting?: boolean; // For 'restore' mode
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRestoreResult {
|
||||||
|
service: IService;
|
||||||
|
platformResourcesRestored: number;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ import {
|
|||||||
INetworkStats,
|
INetworkStats,
|
||||||
IContainerStats,
|
IContainerStats,
|
||||||
IMetric,
|
IMetric,
|
||||||
|
ITrafficStats,
|
||||||
|
IBackup,
|
||||||
|
IRestoreOptions,
|
||||||
|
IRestoreResult,
|
||||||
|
IBackupPasswordStatus,
|
||||||
} from '../types/api.types';
|
} from '../types/api.types';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -204,4 +209,55 @@ export class ApiService {
|
|||||||
async getNetworkStats(): Promise<IApiResponse<INetworkStats>> {
|
async getNetworkStats(): Promise<IApiResponse<INetworkStats>> {
|
||||||
return firstValueFrom(this.http.get<IApiResponse<INetworkStats>>('/api/network/stats'));
|
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}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backups
|
||||||
|
async getBackups(): Promise<IApiResponse<IBackup[]>> {
|
||||||
|
return firstValueFrom(this.http.get<IApiResponse<IBackup[]>>('/api/backups'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getServiceBackups(serviceName: string): Promise<IApiResponse<IBackup[]>> {
|
||||||
|
return firstValueFrom(this.http.get<IApiResponse<IBackup[]>>(`/api/services/${serviceName}/backups`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBackup(serviceName: string): Promise<IApiResponse<IBackup>> {
|
||||||
|
return firstValueFrom(this.http.post<IApiResponse<IBackup>>(`/api/services/${serviceName}/backup`, {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBackup(backupId: number): Promise<IApiResponse<IBackup>> {
|
||||||
|
return firstValueFrom(this.http.get<IApiResponse<IBackup>>(`/api/backups/${backupId}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBackup(backupId: number): Promise<IApiResponse<void>> {
|
||||||
|
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/backups/${backupId}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
getBackupDownloadUrl(backupId: number): string {
|
||||||
|
return `/api/backups/${backupId}/download`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreBackup(backupId: number, options: IRestoreOptions): Promise<IApiResponse<IRestoreResult>> {
|
||||||
|
return firstValueFrom(
|
||||||
|
this.http.post<IApiResponse<IRestoreResult>>('/api/backups/restore', {
|
||||||
|
backupId,
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setBackupPassword(password: string): Promise<IApiResponse<void>> {
|
||||||
|
return firstValueFrom(
|
||||||
|
this.http.post<IApiResponse<void>>('/api/settings/backup-password', { password })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkBackupPassword(): Promise<IApiResponse<IBackupPasswordStatus>> {
|
||||||
|
return firstValueFrom(
|
||||||
|
this.http.get<IApiResponse<IBackupPasswordStatus>>('/api/settings/backup-password')
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,18 @@ export interface ISystemStatus {
|
|||||||
dns: { configured: boolean };
|
dns: { configured: boolean };
|
||||||
ssl: { configured: boolean; certificateCount: number };
|
ssl: { configured: boolean; certificateCount: number };
|
||||||
services: { total: number; running: number; stopped: 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 {
|
export interface IDomain {
|
||||||
@@ -322,3 +333,46 @@ export interface IStatsUpdateMessage {
|
|||||||
stats: IContainerStats;
|
stats: IContainerStats;
|
||||||
timestamp: number;
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup Types
|
||||||
|
export interface IBackup {
|
||||||
|
id?: number;
|
||||||
|
serviceId: number;
|
||||||
|
serviceName: string;
|
||||||
|
filename: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
createdAt: number;
|
||||||
|
includesImage: boolean;
|
||||||
|
platformResources: TPlatformServiceType[];
|
||||||
|
checksum: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TRestoreMode = 'restore' | 'import' | 'clone';
|
||||||
|
|
||||||
|
export interface IRestoreOptions {
|
||||||
|
mode: TRestoreMode;
|
||||||
|
newServiceName?: string;
|
||||||
|
overwriteExisting?: boolean;
|
||||||
|
skipPlatformData?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRestoreResult {
|
||||||
|
service: IService;
|
||||||
|
platformResourcesRestored: number;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBackupPasswordStatus {
|
||||||
|
isConfigured: boolean;
|
||||||
|
}
|
||||||
|
|||||||
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 { ButtonComponent } from '../../ui/button/button.component';
|
||||||
import { BadgeComponent } from '../../ui/badge/badge.component';
|
import { BadgeComponent } from '../../ui/badge/badge.component';
|
||||||
import { SkeletonComponent } from '../../ui/skeleton/skeleton.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({
|
@Component({
|
||||||
selector: 'app-dashboard',
|
selector: 'app-dashboard',
|
||||||
@@ -28,6 +32,10 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
|||||||
ButtonComponent,
|
ButtonComponent,
|
||||||
BadgeComponent,
|
BadgeComponent,
|
||||||
SkeletonComponent,
|
SkeletonComponent,
|
||||||
|
TrafficCardComponent,
|
||||||
|
PlatformServicesCardComponent,
|
||||||
|
CertificatesCardComponent,
|
||||||
|
ResourceUsageCardComponent,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
@@ -63,7 +71,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else if (status()) {
|
} @else if (status()) {
|
||||||
<!-- Stats Grid -->
|
<!-- Row 1: Key Stats Grid -->
|
||||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<ui-card>
|
<ui-card>
|
||||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
<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>
|
</ui-card>
|
||||||
</div>
|
</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">
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<!-- Certificates Health -->
|
||||||
|
<app-certificates-card [health]="status()!.certificateHealth" />
|
||||||
|
|
||||||
<!-- Reverse Proxy -->
|
<!-- Reverse Proxy -->
|
||||||
<ui-card>
|
<ui-card>
|
||||||
<ui-card-header class="flex flex-col space-y-1.5">
|
<ui-card-header class="flex flex-col space-y-1.5">
|
||||||
@@ -139,56 +162,42 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
|||||||
</ui-badge>
|
</ui-badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-sm">Certificates</span>
|
<span class="text-sm">Routes</span>
|
||||||
<span class="text-sm font-medium">{{ status()!.reverseProxy.https.certificates }}</span>
|
<span class="text-sm font-medium">{{ status()!.reverseProxy.routes }}</span>
|
||||||
</div>
|
</div>
|
||||||
</ui-card-content>
|
</ui-card-content>
|
||||||
</ui-card>
|
</ui-card>
|
||||||
|
|
||||||
<!-- DNS -->
|
<!-- DNS & SSL Combined -->
|
||||||
<ui-card>
|
<ui-card>
|
||||||
<ui-card-header class="flex flex-col space-y-1.5">
|
<ui-card-header class="flex flex-col space-y-1.5">
|
||||||
<ui-card-title>DNS</ui-card-title>
|
<ui-card-title>DNS & SSL</ui-card-title>
|
||||||
<ui-card-description>DNS configuration status</ui-card-description>
|
<ui-card-description>Configuration status</ui-card-description>
|
||||||
</ui-card-header>
|
</ui-card-header>
|
||||||
<ui-card-content>
|
<ui-card-content class="space-y-2">
|
||||||
<div class="flex items-center justify-between">
|
<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'">
|
<ui-badge [variant]="status()!.dns.configured ? 'success' : 'secondary'">
|
||||||
{{ status()!.dns.configured ? 'Configured' : 'Not configured' }}
|
{{ status()!.dns.configured ? 'Configured' : 'Not configured' }}
|
||||||
</ui-badge>
|
</ui-badge>
|
||||||
</div>
|
</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">
|
<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'">
|
<ui-badge [variant]="status()!.ssl.configured ? 'success' : 'secondary'">
|
||||||
{{ status()!.ssl.configured ? 'Configured' : 'Not configured' }}
|
{{ status()!.ssl.configured ? 'Configured' : 'Not configured' }}
|
||||||
</ui-badge>
|
</ui-badge>
|
||||||
</div>
|
</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-content>
|
||||||
</ui-card>
|
</ui-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
<!-- Row 5: Quick Actions -->
|
||||||
<ui-card>
|
<ui-card>
|
||||||
<ui-card-header class="flex flex-col space-y-1.5">
|
<ui-card-header class="flex flex-col space-y-1.5">
|
||||||
<ui-card-title>Quick Actions</ui-card-title>
|
<ui-card-title>Quick Actions</ui-card-title>
|
||||||
<ui-card-description>Common tasks and shortcuts</ui-card-description>
|
<ui-card-description>Common tasks and shortcuts</ui-card-description>
|
||||||
</ui-card-header>
|
</ui-card-header>
|
||||||
<ui-card-content class="flex gap-4">
|
<ui-card-content class="flex flex-wrap gap-4">
|
||||||
<a routerLink="/services/create">
|
<a routerLink="/services/create">
|
||||||
<button uiButton>
|
<button uiButton>
|
||||||
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
@@ -200,6 +209,9 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
|||||||
<a routerLink="/services">
|
<a routerLink="/services">
|
||||||
<button uiButton variant="outline">View All Services</button>
|
<button uiButton variant="outline">View All Services</button>
|
||||||
</a>
|
</a>
|
||||||
|
<a routerLink="/platform-services">
|
||||||
|
<button uiButton variant="outline">Platform Services</button>
|
||||||
|
</a>
|
||||||
<a routerLink="/network">
|
<a routerLink="/network">
|
||||||
<button uiButton variant="outline">Manage Domains</button>
|
<button uiButton variant="outline">Manage Domains</button>
|
||||||
</a>
|
</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { ApiService } from '../../core/services/api.service';
|
|||||||
import { ToastService } from '../../core/services/toast.service';
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
import { LogStreamService } from '../../core/services/log-stream.service';
|
import { LogStreamService } from '../../core/services/log-stream.service';
|
||||||
import { WebSocketService } from '../../core/services/websocket.service';
|
import { WebSocketService } from '../../core/services/websocket.service';
|
||||||
import { IService, IServiceUpdate, IPlatformResource, IContainerStats, IMetric } from '../../core/types/api.types';
|
import { IService, IServiceUpdate, IPlatformResource, IContainerStats, IMetric, IBackup, TRestoreMode } from '../../core/types/api.types';
|
||||||
import { ContainerStatsComponent } from '../../shared/components/container-stats/container-stats.component';
|
import { ContainerStatsComponent } from '../../shared/components/container-stats/container-stats.component';
|
||||||
import {
|
import {
|
||||||
CardComponent,
|
CardComponent,
|
||||||
@@ -333,6 +333,79 @@ import {
|
|||||||
</ui-card-content>
|
</ui-card-content>
|
||||||
</ui-card>
|
</ui-card>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- Backups -->
|
||||||
|
<ui-card>
|
||||||
|
<ui-card-header class="flex flex-row items-center justify-between">
|
||||||
|
<div class="flex flex-col space-y-1.5">
|
||||||
|
<ui-card-title>Backups</ui-card-title>
|
||||||
|
<ui-card-description>Create and manage service backups</ui-card-description>
|
||||||
|
</div>
|
||||||
|
<button uiButton size="sm" (click)="createBackup()" [disabled]="backupLoading()">
|
||||||
|
@if (backupLoading()) {
|
||||||
|
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
Creating...
|
||||||
|
} @else {
|
||||||
|
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||||
|
</svg>
|
||||||
|
Create Backup
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</ui-card-header>
|
||||||
|
<ui-card-content>
|
||||||
|
@if (backups().length > 0) {
|
||||||
|
<div class="space-y-3">
|
||||||
|
@for (backup of backups(); track backup.id) {
|
||||||
|
<div class="border rounded-lg p-3 flex items-center justify-between">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-sm font-medium">{{ formatDate(backup.createdAt) }}</div>
|
||||||
|
<div class="text-xs text-muted-foreground flex items-center gap-2">
|
||||||
|
<span>{{ formatBytes(backup.sizeBytes) }}</span>
|
||||||
|
@if (backup.includesImage) {
|
||||||
|
<ui-badge variant="outline" class="text-xs">Docker Image</ui-badge>
|
||||||
|
}
|
||||||
|
@for (res of backup.platformResources; track res) {
|
||||||
|
<ui-badge variant="outline" class="text-xs">{{ res }}</ui-badge>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a [href]="getBackupDownloadUrl(backup.id!)" class="inline-flex" download>
|
||||||
|
<button uiButton variant="ghost" size="sm" title="Download">
|
||||||
|
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
<button uiButton variant="ghost" size="sm" (click)="openRestoreDialog(backup)" title="Restore">
|
||||||
|
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button uiButton variant="ghost" size="sm" (click)="openDeleteBackupDialog(backup)" title="Delete">
|
||||||
|
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="text-center py-6 text-muted-foreground">
|
||||||
|
<svg class="h-12 w-12 mx-auto mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm">No backups yet</p>
|
||||||
|
<p class="text-xs mt-1">Create a backup to protect your service data</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</ui-card-content>
|
||||||
|
</ui-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logs Section -->
|
<!-- Logs Section -->
|
||||||
@@ -420,6 +493,85 @@ import {
|
|||||||
</button>
|
</button>
|
||||||
</ui-dialog-footer>
|
</ui-dialog-footer>
|
||||||
</ui-dialog>
|
</ui-dialog>
|
||||||
|
|
||||||
|
<!-- Delete Backup Dialog -->
|
||||||
|
<ui-dialog [open]="deleteBackupDialogOpen()" (openChange)="deleteBackupDialogOpen.set($event)">
|
||||||
|
<ui-dialog-header>
|
||||||
|
<ui-dialog-title>Delete Backup</ui-dialog-title>
|
||||||
|
<ui-dialog-description>
|
||||||
|
Are you sure you want to delete this backup from {{ formatDate(selectedBackup()?.createdAt || 0) }}? This action cannot be undone.
|
||||||
|
</ui-dialog-description>
|
||||||
|
</ui-dialog-header>
|
||||||
|
<ui-dialog-footer>
|
||||||
|
<button uiButton variant="outline" (click)="deleteBackupDialogOpen.set(false)">Cancel</button>
|
||||||
|
<button uiButton variant="destructive" (click)="deleteBackup()" [disabled]="backupLoading()">
|
||||||
|
Delete Backup
|
||||||
|
</button>
|
||||||
|
</ui-dialog-footer>
|
||||||
|
</ui-dialog>
|
||||||
|
|
||||||
|
<!-- Restore Dialog -->
|
||||||
|
<ui-dialog [open]="restoreDialogOpen()" (openChange)="restoreDialogOpen.set($event)">
|
||||||
|
<ui-dialog-header>
|
||||||
|
<ui-dialog-title>Restore Backup</ui-dialog-title>
|
||||||
|
<ui-dialog-description>
|
||||||
|
Choose how to restore this backup from {{ formatDate(selectedBackup()?.createdAt || 0) }}.
|
||||||
|
</ui-dialog-description>
|
||||||
|
</ui-dialog-header>
|
||||||
|
<div class="space-y-4 py-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label uiLabel>Restore Mode</label>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-muted/50" [class.border-primary]="restoreMode() === 'restore'">
|
||||||
|
<input type="radio" name="restoreMode" value="restore" [checked]="restoreMode() === 'restore'" (change)="restoreMode.set('restore')" class="h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-sm">Restore to existing service</div>
|
||||||
|
<div class="text-xs text-muted-foreground">Overwrite current service with backup data</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-muted/50" [class.border-primary]="restoreMode() === 'clone'">
|
||||||
|
<input type="radio" name="restoreMode" value="clone" [checked]="restoreMode() === 'clone'" (change)="restoreMode.set('clone')" class="h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-sm">Clone as new service</div>
|
||||||
|
<div class="text-xs text-muted-foreground">Create a copy of the service with backup data</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (restoreMode() === 'clone') {
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label uiLabel>New Service Name</label>
|
||||||
|
<input uiInput [(ngModel)]="restoreNewServiceName" placeholder="e.g. my-service-clone" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (restoreMode() === 'restore') {
|
||||||
|
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-3">
|
||||||
|
<div class="flex gap-2 items-start">
|
||||||
|
<svg class="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" 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>
|
||||||
|
<div class="text-sm text-amber-500">
|
||||||
|
<strong>Warning:</strong> This will overwrite the current service configuration and data.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<ui-dialog-footer>
|
||||||
|
<button uiButton variant="outline" (click)="restoreDialogOpen.set(false)">Cancel</button>
|
||||||
|
<button uiButton (click)="restoreBackup()" [disabled]="backupLoading() || (restoreMode() === 'clone' && !restoreNewServiceName)">
|
||||||
|
@if (backupLoading()) {
|
||||||
|
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
Restoring...
|
||||||
|
} @else {
|
||||||
|
Restore Backup
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</ui-dialog-footer>
|
||||||
|
</ui-dialog>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class ServiceDetailComponent implements OnInit, OnDestroy {
|
export class ServiceDetailComponent implements OnInit, OnDestroy {
|
||||||
@@ -437,11 +589,18 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
|
|||||||
platformResources = signal<IPlatformResource[]>([]);
|
platformResources = signal<IPlatformResource[]>([]);
|
||||||
stats = signal<IContainerStats | null>(null);
|
stats = signal<IContainerStats | null>(null);
|
||||||
metrics = signal<IMetric[]>([]);
|
metrics = signal<IMetric[]>([]);
|
||||||
|
backups = signal<IBackup[]>([]);
|
||||||
loading = signal(false);
|
loading = signal(false);
|
||||||
actionLoading = signal(false);
|
actionLoading = signal(false);
|
||||||
|
backupLoading = signal(false);
|
||||||
editMode = signal(false);
|
editMode = signal(false);
|
||||||
deleteDialogOpen = signal(false);
|
deleteDialogOpen = signal(false);
|
||||||
|
deleteBackupDialogOpen = signal(false);
|
||||||
|
restoreDialogOpen = signal(false);
|
||||||
|
selectedBackup = signal<IBackup | null>(null);
|
||||||
|
restoreMode = signal<TRestoreMode>('restore');
|
||||||
autoScroll = true;
|
autoScroll = true;
|
||||||
|
restoreNewServiceName = '';
|
||||||
|
|
||||||
editForm: IServiceUpdate = {};
|
editForm: IServiceUpdate = {};
|
||||||
|
|
||||||
@@ -506,6 +665,9 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
|
|||||||
this.loadPlatformResources(name);
|
this.loadPlatformResources(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load backups for this service
|
||||||
|
this.loadBackups(name);
|
||||||
|
|
||||||
// Load initial stats and metrics if service is running
|
// Load initial stats and metrics if service is running
|
||||||
// (WebSocket will keep stats updated in real-time)
|
// (WebSocket will keep stats updated in real-time)
|
||||||
if (response.data.status === 'running') {
|
if (response.data.status === 'running') {
|
||||||
@@ -737,4 +899,126 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
|
|||||||
this.deleteDialogOpen.set(false);
|
this.deleteDialogOpen.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backup methods
|
||||||
|
async loadBackups(name: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await this.api.getServiceBackups(name);
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.backups.set(response.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent fail - backups are optional
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBackup(): Promise<void> {
|
||||||
|
const name = this.service()?.name;
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
this.backupLoading.set(true);
|
||||||
|
try {
|
||||||
|
const response = await this.api.createBackup(name);
|
||||||
|
if (response.success) {
|
||||||
|
this.toast.success('Backup created successfully');
|
||||||
|
this.loadBackups(name);
|
||||||
|
} else {
|
||||||
|
this.toast.error(response.error || 'Failed to create backup');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.toast.error('Failed to create backup');
|
||||||
|
} finally {
|
||||||
|
this.backupLoading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openDeleteBackupDialog(backup: IBackup): void {
|
||||||
|
this.selectedBackup.set(backup);
|
||||||
|
this.deleteBackupDialogOpen.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBackup(): Promise<void> {
|
||||||
|
const backup = this.selectedBackup();
|
||||||
|
const serviceName = this.service()?.name;
|
||||||
|
if (!backup?.id || !serviceName) return;
|
||||||
|
|
||||||
|
this.backupLoading.set(true);
|
||||||
|
try {
|
||||||
|
const response = await this.api.deleteBackup(backup.id);
|
||||||
|
if (response.success) {
|
||||||
|
this.toast.success('Backup deleted');
|
||||||
|
this.loadBackups(serviceName);
|
||||||
|
} else {
|
||||||
|
this.toast.error(response.error || 'Failed to delete backup');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.toast.error('Failed to delete backup');
|
||||||
|
} finally {
|
||||||
|
this.backupLoading.set(false);
|
||||||
|
this.deleteBackupDialogOpen.set(false);
|
||||||
|
this.selectedBackup.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openRestoreDialog(backup: IBackup): void {
|
||||||
|
this.selectedBackup.set(backup);
|
||||||
|
this.restoreMode.set('restore');
|
||||||
|
this.restoreNewServiceName = '';
|
||||||
|
this.restoreDialogOpen.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreBackup(): Promise<void> {
|
||||||
|
const backup = this.selectedBackup();
|
||||||
|
const serviceName = this.service()?.name;
|
||||||
|
if (!backup?.id || !serviceName) return;
|
||||||
|
|
||||||
|
const mode = this.restoreMode();
|
||||||
|
if (mode === 'clone' && !this.restoreNewServiceName.trim()) {
|
||||||
|
this.toast.error('Please enter a new service name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.backupLoading.set(true);
|
||||||
|
try {
|
||||||
|
const response = await this.api.restoreBackup(backup.id, {
|
||||||
|
mode,
|
||||||
|
newServiceName: mode === 'clone' ? this.restoreNewServiceName.trim() : undefined,
|
||||||
|
overwriteExisting: mode === 'restore',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
if (response.data.warnings && response.data.warnings.length > 0) {
|
||||||
|
this.toast.warning(`Restored with warnings: ${response.data.warnings.join(', ')}`);
|
||||||
|
} else {
|
||||||
|
this.toast.success('Backup restored successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'clone') {
|
||||||
|
this.router.navigate(['/services', response.data.service.name]);
|
||||||
|
} else {
|
||||||
|
this.loadService(serviceName);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.toast.error(response.error || 'Failed to restore backup');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.toast.error('Failed to restore backup');
|
||||||
|
} finally {
|
||||||
|
this.backupLoading.set(false);
|
||||||
|
this.restoreDialogOpen.set(false);
|
||||||
|
this.selectedBackup.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getBackupDownloadUrl(backupId: number): string {
|
||||||
|
return this.api.getBackupDownloadUrl(backupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user