16 Commits

Author SHA1 Message Date
bc2ed4b03a v1.9.1 2025-11-27 22:35:35 +00:00
e4dd4cce0a fix(ui): Correct import success toast and add VS Code launch/tasks recommendations for the UI 2025-11-27 22:35:35 +00:00
34c90e21db v1.9.0 2025-11-27 22:29:36 +00:00
ea7bb1395f feat(backups): Add backup import API and improve backup download/import flow in UI 2025-11-27 22:29:36 +00:00
c529dfe34d v1.8.0 2025-11-27 21:42:07 +00:00
6ba7e655e3 feat(backup): Add backup scheduling system with GFS retention, API and UI integration 2025-11-27 21:42:07 +00:00
c5d239ab28 v1.7.0 2025-11-27 13:48:11 +00:00
5cd7e7c252 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.
2025-11-27 13:48:11 +00:00
e7ade45097 v1.6.0 2025-11-27 09:50:06 +00:00
7b159a3486 feat(ui.dashboard): Add Resource Usage card to dashboard and make dashboard cards full-height; add VSCode launch/tasks/config 2025-11-27 09:50:06 +00:00
9470c7911d v1.5.0 2025-11-27 09:26:04 +00:00
3d7727c304 feat(network): Add traffic stats endpoint and dashboard UI; enhance platform services and certificate health reporting 2025-11-27 09:26:04 +00:00
ff5b51072f v1.4.0 2025-11-26 22:05:25 +00:00
633cbe696e feat(platform-services): Add ClickHouse platform service support and improve related healthchecks and tooling 2025-11-26 22:05:25 +00:00
0247ab45c7 v1.3.0 2025-11-26 18:54:20 +00:00
0d932239d2 feat(platform-services): Add ClickHouse platform service support (provider, types, provisioning, UI and port mappings) 2025-11-26 18:54:20 +00:00
32 changed files with 6328 additions and 56 deletions

View File

@@ -1,5 +1,83 @@
# Changelog
## 2025-11-27 - 1.9.1 - fix(ui)
Correct import success toast and add VS Code launch/tasks recommendations for the UI
- Fix backup import success toast in backups-tab.component to reference response.data.service.name (previously response.data.serviceName), preventing incorrect service name display.
- Add VS Code workspace settings for the UI: extensions recommendation, launch configurations for 'ng serve' and 'ng test', and npm tasks for start/test to simplify local development and debugging.
## 2025-11-27 - 1.9.0 - feat(backups)
Add backup import API and improve backup download/import flow in UI
- Backend: add /api/backups/import endpoint to accept multipart file uploads or JSON with a URL and import backups (saves temp file, validates .tar.enc, calls backupManager.restoreBackup in import mode).
- Backend: server-side import handler downloads remote backup URLs, stores temporary file, invokes restore/import logic and cleans up temp files.
- Frontend: add downloadBackup, importBackupFromFile and importBackupFromUrl methods to ApiService; trigger browser download using Blob and object URL with Authorization header.
- Frontend: replace raw download link in service detail UI with a Download button that calls downloadBackup and shows success/error toasts.
- Dev: add VS Code launch, tasks and recommended extensions for the ui workspace to simplify local development.
## 2025-11-27 - 1.8.0 - feat(backup)
Add backup scheduling system with GFS retention, API and UI integration
- Introduce backup scheduling subsystem (BackupScheduler) and integrate it into Onebox lifecycle (init & shutdown)
- Extend BackupManager.createBackup to accept schedule metadata (scheduleId) so scheduled runs are tracked
- Add GFS-style retention policy support (IRetentionPolicy + RETENTION_PRESETS) and expose per-tier retention in types
- Database migrations and repository changes: create backups and backup_schedules tables, add schedule_id, per-tier retention columns, and scope (all/pattern/service) support (migrations up to version 12)
- HTTP API: add backup schedule endpoints (GET/POST/PUT/DELETE /api/backup-schedules), trigger endpoint (/api/backup-schedules/:id/trigger), and service-scoped schedule endpoints
- UI: add API client methods for backup schedules and register a Backups tab in Services UI to surface schedules/backups
- Add task scheduling dependency (@push.rocks/taskbuffer) and export it via plugins.ts; update deno.json accordingly
- Type and repository updates across codebase to support schedule-aware backups, schedule CRUD, and retention enforcement
## 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)
Add ClickHouse platform service support (provider, types, provisioning, UI and port mappings)
- Introduce ClickHouse as a first-class platform service: added ClickHouseProvider and registered it in PlatformServicesManager
- Support provisioning ClickHouse resources for user services and storing encrypted credentials in platform_resources
- Add ClickHouse to core types (TPlatformServiceType, IPlatformRequirements, IServiceDeployOptions) and service DB handling so services can request ClickHouse
- Inject ClickHouse-related environment variables into deployed services (CLICKHOUSE_* mappings) when provisioning resources
- Expose ClickHouse default port (8123) in platform port mappings / network targets
- UI: add checkbox and description for enabling ClickHouse during service creation; form now submits enableClickHouse
- Add VS Code recommendations and launch/tasks for the UI development workflow
## 2025-11-26 - 1.2.1 - fix(platform-services/minio)
Improve MinIO provider: reuse existing data and credentials, use host-bound port for provisioning, and safer provisioning/deprovisioning

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/onebox",
"version": "1.2.1",
"version": "1.9.1",
"exports": "./mod.ts",
"nodeModulesDir": "auto",
"tasks": {
@@ -21,7 +21,8 @@
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3",
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0",
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.2.0",
"@push.rocks/smarts3": "npm:@push.rocks/smarts3@^5.1.0"
"@push.rocks/smarts3": "npm:@push.rocks/smarts3@^5.1.0",
"@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^3.1.0"
},
"compilerOptions": {
"lib": [

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/onebox",
"version": "1.2.1",
"version": "1.9.1",
"description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers",
"main": "mod.ts",
"type": "module",

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/onebox',
version: '1.2.1',
version: '1.9.1',
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
}

1117
ts/classes/backup-manager.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,650 @@
/**
* Backup Scheduler for Onebox
*
* Uses @push.rocks/taskbuffer for cron-based scheduled backups
* with GFS (Grandfather-Father-Son) time-window based retention scheme.
*/
import * as plugins from '../plugins.ts';
import type {
IBackupSchedule,
IBackupScheduleCreate,
IBackupScheduleUpdate,
IService,
IRetentionPolicy,
} from '../types.ts';
import { RETENTION_PRESETS } from '../types.ts';
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
import type { Onebox } from './onebox.ts';
export class BackupScheduler {
private oneboxRef: Onebox;
private taskManager!: plugins.taskbuffer.TaskManager;
private scheduledTasks: Map<number, plugins.taskbuffer.Task> = new Map();
private initialized = false;
constructor(oneboxRef: Onebox) {
this.oneboxRef = oneboxRef;
// TaskManager is created in init() to avoid log spam before ready
}
/**
* Initialize the scheduler and load enabled schedules
*/
async init(): Promise<void> {
if (this.initialized) {
logger.warn('BackupScheduler already initialized');
return;
}
try {
// Create TaskManager here (not in constructor) to avoid "no cronjobs" log spam
this.taskManager = new plugins.taskbuffer.TaskManager();
// Add heartbeat task immediately to prevent "no cronjobs specified" log spam
// This runs hourly and does nothing, but keeps taskbuffer happy
const heartbeatTask = new plugins.taskbuffer.Task({
name: 'backup-scheduler-heartbeat',
taskFunction: async () => {
// No-op heartbeat task
},
});
this.taskManager.addAndScheduleTask(heartbeatTask, '0 * * * *'); // Hourly
// Load all enabled schedules from database
const schedules = this.oneboxRef.database.getEnabledBackupSchedules();
for (const schedule of schedules) {
await this.registerTask(schedule);
}
// Start the task manager (activates cron scheduling)
await this.taskManager.start();
this.initialized = true;
logger.info(`Backup scheduler started with ${schedules.length} enabled schedule(s)`);
} catch (error) {
logger.error(`Failed to initialize backup scheduler: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Stop the scheduler
*/
async stop(): Promise<void> {
if (!this.initialized || !this.taskManager) return;
try {
await this.taskManager.stop();
this.scheduledTasks.clear();
this.initialized = false;
logger.info('Backup scheduler stopped');
} catch (error) {
logger.error(`Failed to stop backup scheduler: ${getErrorMessage(error)}`);
}
}
/**
* Create a new backup schedule
*/
async createSchedule(request: IBackupScheduleCreate): Promise<IBackupSchedule> {
// Validate based on scope type
let serviceId: number | undefined;
let serviceName: string | undefined;
switch (request.scopeType) {
case 'service':
// Validate service exists
if (!request.serviceName) {
throw new Error('serviceName is required for service-specific schedules');
}
const service = this.oneboxRef.database.getServiceByName(request.serviceName);
if (!service) {
throw new Error(`Service not found: ${request.serviceName}`);
}
serviceId = service.id!;
serviceName = service.name;
break;
case 'pattern':
// Validate pattern is provided
if (!request.scopePattern) {
throw new Error('scopePattern is required for pattern-based schedules');
}
// Validate pattern matches at least one service
const matchingServices = this.getServicesMatchingPattern(request.scopePattern);
if (matchingServices.length === 0) {
logger.warn(`Pattern "${request.scopePattern}" currently matches no services`);
}
break;
case 'all':
// No validation needed for global schedules
break;
default:
throw new Error(`Invalid scope type: ${request.scopeType}`);
}
// Use provided cron expression
const cronExpression = request.cronExpression;
// Calculate next run time
const nextRunAt = this.calculateNextRun(cronExpression);
// Create schedule in database
const schedule = this.oneboxRef.database.createBackupSchedule({
scopeType: request.scopeType,
scopePattern: request.scopePattern,
serviceId,
serviceName,
cronExpression,
retention: request.retention,
enabled: request.enabled !== false,
lastRunAt: null,
nextRunAt,
lastStatus: null,
lastError: null,
createdAt: Date.now(),
updatedAt: Date.now(),
});
// Register task if enabled
if (schedule.enabled) {
await this.registerTask(schedule);
}
const scopeDesc = this.getScopeDescription(schedule);
const retentionDesc = this.getRetentionDescription(schedule.retention);
logger.info(`Backup schedule created: ${schedule.id} for ${scopeDesc} (${retentionDesc})`);
return schedule;
}
/**
* Update an existing backup schedule
*/
async updateSchedule(scheduleId: number, updates: IBackupScheduleUpdate): Promise<IBackupSchedule> {
const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId);
if (!schedule) {
throw new Error(`Backup schedule not found: ${scheduleId}`);
}
// Deschedule existing task if present
await this.descheduleTask(scheduleId);
// Update database
this.oneboxRef.database.updateBackupSchedule(scheduleId, updates);
// Get updated schedule
const updatedSchedule = this.oneboxRef.database.getBackupScheduleById(scheduleId)!;
// Calculate new next run time if cron changed
if (updates.cronExpression) {
const nextRunAt = this.calculateNextRun(updatedSchedule.cronExpression);
this.oneboxRef.database.updateBackupSchedule(scheduleId, { nextRunAt });
}
// Re-register task if enabled
if (updatedSchedule.enabled) {
await this.registerTask(updatedSchedule);
}
logger.info(`Backup schedule updated: ${scheduleId}`);
return this.oneboxRef.database.getBackupScheduleById(scheduleId)!;
}
/**
* Delete a backup schedule
*/
async deleteSchedule(scheduleId: number): Promise<void> {
const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId);
if (!schedule) {
throw new Error(`Backup schedule not found: ${scheduleId}`);
}
// Deschedule task
await this.descheduleTask(scheduleId);
// Delete from database
this.oneboxRef.database.deleteBackupSchedule(scheduleId);
logger.info(`Backup schedule deleted: ${scheduleId}`);
}
/**
* Trigger immediate backup for a schedule
*/
async triggerBackup(scheduleId: number): Promise<void> {
const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId);
if (!schedule) {
throw new Error(`Backup schedule not found: ${scheduleId}`);
}
logger.info(`Manually triggering backup for schedule ${scheduleId}`);
await this.executeBackup(schedule);
}
/**
* Get all schedules
*/
getAllSchedules(): IBackupSchedule[] {
return this.oneboxRef.database.getAllBackupSchedules();
}
/**
* Get schedule by ID
*/
getScheduleById(id: number): IBackupSchedule | null {
return this.oneboxRef.database.getBackupScheduleById(id);
}
/**
* Get schedules for a service
*/
getSchedulesForService(serviceName: string): IBackupSchedule[] {
const service = this.oneboxRef.database.getServiceByName(serviceName);
if (!service) {
return [];
}
return this.oneboxRef.database.getBackupSchedulesByService(service.id!);
}
/**
* Get retention presets
*/
getRetentionPresets(): typeof RETENTION_PRESETS {
return RETENTION_PRESETS;
}
// ========== Private Methods ==========
/**
* Register a task for a schedule
*/
private async registerTask(schedule: IBackupSchedule): Promise<void> {
const taskName = `backup-${schedule.id}`;
const task = new plugins.taskbuffer.Task({
name: taskName,
taskFunction: async () => {
await this.executeBackup(schedule);
},
});
// Add and schedule the task
this.taskManager.addAndScheduleTask(task, schedule.cronExpression);
this.scheduledTasks.set(schedule.id!, task);
// Update next run time in database
this.updateNextRunTime(schedule.id!);
logger.debug(`Registered backup task: ${taskName} with cron: ${schedule.cronExpression}`);
}
/**
* Deschedule a task
*/
private async descheduleTask(scheduleId: number): Promise<void> {
const task = this.scheduledTasks.get(scheduleId);
if (task) {
await this.taskManager.descheduleTask(task);
this.scheduledTasks.delete(scheduleId);
logger.debug(`Descheduled backup task for schedule ${scheduleId}`);
}
}
/**
* Execute a backup for a schedule
*/
private async executeBackup(schedule: IBackupSchedule): Promise<void> {
const scopeDesc = this.getScopeDescription(schedule);
const servicesToBackup = this.getServicesForSchedule(schedule);
if (servicesToBackup.length === 0) {
logger.warn(`No services to backup for schedule ${schedule.id} (${scopeDesc})`);
this.oneboxRef.database.updateBackupSchedule(schedule.id!, {
lastRunAt: Date.now(),
lastStatus: 'success',
lastError: 'No matching services found',
});
this.updateNextRunTime(schedule.id!);
return;
}
const retentionDesc = this.getRetentionDescription(schedule.retention);
logger.info(`Executing scheduled backup for ${scopeDesc}: ${servicesToBackup.length} service(s) (${retentionDesc})`);
let successCount = 0;
let failCount = 0;
const errors: string[] = [];
for (const service of servicesToBackup) {
try {
// Create backup with schedule ID
await this.oneboxRef.backupManager.createBackup(service.name, {
scheduleId: schedule.id,
});
// Apply time-window based retention policy for this service
await this.applyRetention(schedule, service.id!);
successCount++;
logger.success(`Scheduled backup completed for ${service.name}`);
} catch (error) {
const errorMessage = getErrorMessage(error);
logger.error(`Scheduled backup failed for ${service.name}: ${errorMessage}`);
errors.push(`${service.name}: ${errorMessage}`);
failCount++;
}
}
// Update schedule status
const lastStatus = failCount === 0 ? 'success' : 'failed';
const lastError = errors.length > 0 ? errors.join('; ') : null;
this.oneboxRef.database.updateBackupSchedule(schedule.id!, {
lastRunAt: Date.now(),
lastStatus,
lastError,
});
if (failCount === 0) {
logger.success(`Scheduled backup completed for ${scopeDesc}: ${successCount} service(s)`);
} else {
logger.warn(`Scheduled backup partially failed for ${scopeDesc}: ${successCount} succeeded, ${failCount} failed`);
}
// Update next run time
this.updateNextRunTime(schedule.id!);
}
/**
* Apply time-window based retention policy
* Works correctly regardless of backup frequency (cron schedule)
*/
private async applyRetention(schedule: IBackupSchedule, serviceId: number): Promise<void> {
// Get all backups for this schedule and service
const allBackups = this.oneboxRef.database.getBackupsByService(serviceId);
const backups = allBackups.filter(b => b.scheduleId === schedule.id);
if (backups.length === 0) {
return;
}
const { hourly, daily, weekly, monthly } = schedule.retention;
const now = Date.now();
const toKeep = new Set<number>();
// Hourly: Keep up to N most recent backups from last 24 hours
if (hourly > 0) {
const recentBackups = backups
.filter(b => now - b.createdAt < 24 * 60 * 60 * 1000)
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, hourly);
recentBackups.forEach(b => toKeep.add(b.id!));
}
// Daily: Keep oldest backup per day for last N days
if (daily > 0) {
for (let i = 0; i < daily; i++) {
const dayStart = this.getStartOfDay(now, i);
const dayEnd = dayStart + 24 * 60 * 60 * 1000;
const dayBackups = backups.filter(b =>
b.createdAt >= dayStart && b.createdAt < dayEnd
);
if (dayBackups.length > 0) {
// Keep oldest from this day (most representative)
const oldest = dayBackups.sort((a, b) => a.createdAt - b.createdAt)[0];
toKeep.add(oldest.id!);
}
}
}
// Weekly: Keep oldest backup per week for last N weeks
if (weekly > 0) {
for (let i = 0; i < weekly; i++) {
const weekStart = this.getStartOfWeek(now, i);
const weekEnd = weekStart + 7 * 24 * 60 * 60 * 1000;
const weekBackups = backups.filter(b =>
b.createdAt >= weekStart && b.createdAt < weekEnd
);
if (weekBackups.length > 0) {
const oldest = weekBackups.sort((a, b) => a.createdAt - b.createdAt)[0];
toKeep.add(oldest.id!);
}
}
}
// Monthly: Keep oldest backup per month for last N months
if (monthly > 0) {
for (let i = 0; i < monthly; i++) {
const { start, end } = this.getMonthRange(now, i);
const monthBackups = backups.filter(b =>
b.createdAt >= start && b.createdAt < end
);
if (monthBackups.length > 0) {
const oldest = monthBackups.sort((a, b) => a.createdAt - b.createdAt)[0];
toKeep.add(oldest.id!);
}
}
}
// Delete anything not in toKeep
for (const backup of backups) {
if (!toKeep.has(backup.id!)) {
try {
await this.oneboxRef.backupManager.deleteBackup(backup.id!);
logger.info(`Deleted backup ${backup.filename} (retention policy)`);
} catch (error) {
logger.warn(`Failed to delete old backup ${backup.filename}: ${getErrorMessage(error)}`);
}
}
}
}
/**
* Get start of day (midnight) for N days ago
*/
private getStartOfDay(now: number, daysAgo: number): number {
const date = new Date(now);
date.setDate(date.getDate() - daysAgo);
date.setHours(0, 0, 0, 0);
return date.getTime();
}
/**
* Get start of week (Sunday midnight) for N weeks ago
*/
private getStartOfWeek(now: number, weeksAgo: number): number {
const date = new Date(now);
date.setDate(date.getDate() - (weeksAgo * 7) - date.getDay());
date.setHours(0, 0, 0, 0);
return date.getTime();
}
/**
* Get month range for N months ago
*/
private getMonthRange(now: number, monthsAgo: number): { start: number; end: number } {
const date = new Date(now);
date.setMonth(date.getMonth() - monthsAgo);
date.setDate(1);
date.setHours(0, 0, 0, 0);
const start = date.getTime();
date.setMonth(date.getMonth() + 1);
const end = date.getTime();
return { start, end };
}
/**
* Update next run time for a schedule
*/
private updateNextRunTime(scheduleId: number): void {
const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId);
if (!schedule) return;
const nextRunAt = this.calculateNextRun(schedule.cronExpression);
this.oneboxRef.database.updateBackupSchedule(scheduleId, { nextRunAt });
}
/**
* Calculate next run time from cron expression
*/
private calculateNextRun(cronExpression: string): number {
try {
// Get next scheduled runs from task manager
const scheduledTasks = this.taskManager.getScheduledTasks();
// Find our task and get its next run
for (const taskInfo of scheduledTasks) {
if (taskInfo.schedule === cronExpression && taskInfo.nextRun) {
return taskInfo.nextRun.getTime();
}
}
// Fallback: parse cron and calculate next occurrence
// Simple implementation for common patterns
const now = new Date();
const parts = cronExpression.split(' ');
if (parts.length === 5) {
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
// For daily schedules (e.g., "0 2 * * *")
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
const nextRun = new Date(now);
nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0);
if (nextRun <= now) {
nextRun.setDate(nextRun.getDate() + 1);
}
return nextRun.getTime();
}
// For weekly schedules (e.g., "0 2 * * 0")
if (dayOfMonth === '*' && month === '*' && dayOfWeek !== '*') {
const targetDay = parseInt(dayOfWeek);
const nextRun = new Date(now);
nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0);
const currentDay = now.getDay();
let daysUntilTarget = (targetDay - currentDay + 7) % 7;
if (daysUntilTarget === 0 && nextRun <= now) {
daysUntilTarget = 7;
}
nextRun.setDate(nextRun.getDate() + daysUntilTarget);
return nextRun.getTime();
}
// For monthly schedules (e.g., "0 2 1 * *")
if (dayOfMonth !== '*' && month === '*' && dayOfWeek === '*') {
const targetDay = parseInt(dayOfMonth);
const nextRun = new Date(now);
nextRun.setDate(targetDay);
nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0);
if (nextRun <= now) {
nextRun.setMonth(nextRun.getMonth() + 1);
}
return nextRun.getTime();
}
// For yearly schedules (e.g., "0 2 1 1 *")
if (dayOfMonth !== '*' && month !== '*' && dayOfWeek === '*') {
const targetMonth = parseInt(month) - 1; // JavaScript months are 0-indexed
const targetDay = parseInt(dayOfMonth);
const nextRun = new Date(now);
nextRun.setMonth(targetMonth, targetDay);
nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0);
if (nextRun <= now) {
nextRun.setFullYear(nextRun.getFullYear() + 1);
}
return nextRun.getTime();
}
}
// Default: next day at 2 AM
const fallback = new Date(now);
fallback.setDate(fallback.getDate() + 1);
fallback.setHours(2, 0, 0, 0);
return fallback.getTime();
} catch {
// On any error, return tomorrow at 2 AM
const fallback = new Date();
fallback.setDate(fallback.getDate() + 1);
fallback.setHours(2, 0, 0, 0);
return fallback.getTime();
}
}
/**
* Get services that match a schedule based on its scope type
*/
private getServicesForSchedule(schedule: IBackupSchedule): IService[] {
const allServices = this.oneboxRef.database.getAllServices();
switch (schedule.scopeType) {
case 'all':
return allServices;
case 'pattern':
if (!schedule.scopePattern) return [];
return this.getServicesMatchingPattern(schedule.scopePattern);
case 'service':
if (!schedule.serviceId) return [];
const service = allServices.find(s => s.id === schedule.serviceId);
return service ? [service] : [];
default:
return [];
}
}
/**
* Get services that match a glob pattern
*/
private getServicesMatchingPattern(pattern: string): IService[] {
const allServices = this.oneboxRef.database.getAllServices();
return allServices.filter(s => this.matchesGlobPattern(s.name, pattern));
}
/**
* Simple glob pattern matching (supports * and ?)
*/
private matchesGlobPattern(text: string, pattern: string): boolean {
// Convert glob pattern to regex
// Escape special regex characters except * and ?
const regexPattern = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special chars
.replace(/\*/g, '.*') // * matches any characters
.replace(/\?/g, '.'); // ? matches single character
const regex = new RegExp(`^${regexPattern}$`, 'i');
return regex.test(text);
}
/**
* Get human-readable description of a schedule's scope
*/
private getScopeDescription(schedule: IBackupSchedule): string {
switch (schedule.scopeType) {
case 'all':
return 'all services';
case 'pattern':
return `pattern "${schedule.scopePattern}"`;
case 'service':
return `service "${schedule.serviceName}"`;
default:
return 'unknown scope';
}
}
/**
* Get human-readable description of retention policy
*/
private getRetentionDescription(retention: IRetentionPolicy): string {
return `H:${retention.hourly} D:${retention.daily} W:${retention.weekly} M:${retention.monthly}`;
}
}

View File

@@ -79,8 +79,84 @@ export class CaddyLogReceiver {
private recentLogs: ICaddyAccessLog[] = [];
private maxRecentLogs = 100;
// Traffic stats aggregation (hourly rolling window)
private trafficStats: {
timestamp: number;
requestCount: number;
errorCount: number; // 4xx + 5xx
totalDuration: number; // microseconds
totalSize: number; // bytes
statusCounts: Record<string, number>; // "2xx", "3xx", "4xx", "5xx"
}[] = [];
private maxStatsAge = 3600 * 1000; // 1 hour in ms
private statsInterval = 60 * 1000; // 1 minute buckets
constructor(port = 9999) {
this.port = port;
// Initialize first stats bucket
this.trafficStats.push(this.createStatsBucket());
}
/**
* Create a new stats bucket
*/
private createStatsBucket(): typeof this.trafficStats[0] {
return {
timestamp: Math.floor(Date.now() / this.statsInterval) * this.statsInterval,
requestCount: 0,
errorCount: 0,
totalDuration: 0,
totalSize: 0,
statusCounts: { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 },
};
}
/**
* Get current stats bucket, creating new one if needed
*/
private getCurrentStatsBucket(): typeof this.trafficStats[0] {
const now = Date.now();
const currentBucketTime = Math.floor(now / this.statsInterval) * this.statsInterval;
// Get or create current bucket
let bucket = this.trafficStats[this.trafficStats.length - 1];
if (!bucket || bucket.timestamp !== currentBucketTime) {
bucket = this.createStatsBucket();
this.trafficStats.push(bucket);
// Clean up old buckets
const cutoff = now - this.maxStatsAge;
while (this.trafficStats.length > 0 && this.trafficStats[0].timestamp < cutoff) {
this.trafficStats.shift();
}
}
return bucket;
}
/**
* Record a request in traffic stats
*/
private recordTrafficStats(log: ICaddyAccessLog): void {
const bucket = this.getCurrentStatsBucket();
bucket.requestCount++;
bucket.totalDuration += log.duration;
bucket.totalSize += log.size || 0;
// Categorize status code
const statusCategory = Math.floor(log.status / 100);
if (statusCategory === 2) {
bucket.statusCounts['2xx']++;
} else if (statusCategory === 3) {
bucket.statusCounts['3xx']++;
} else if (statusCategory === 4) {
bucket.statusCounts['4xx']++;
bucket.errorCount++;
} else if (statusCategory === 5) {
bucket.statusCounts['5xx']++;
bucket.errorCount++;
}
}
/**
@@ -181,6 +257,9 @@ export class CaddyLogReceiver {
return;
}
// Always record traffic stats (before sampling) for accurate aggregation
this.recordTrafficStats(log);
// Update adaptive sampling
this.updateSampling();
@@ -414,4 +493,57 @@ export class CaddyLogReceiver {
recentLogsCount: this.recentLogs.length,
};
}
/**
* Get aggregated traffic stats for the specified time range
* @param minutes Number of minutes to aggregate (default: 60)
*/
getTrafficStats(minutes = 60): {
requestCount: number;
errorCount: number;
avgResponseTime: number; // in milliseconds
totalBytes: number;
statusCounts: Record<string, number>;
requestsPerMinute: number;
errorRate: number; // percentage
} {
const now = Date.now();
const cutoff = now - (minutes * 60 * 1000);
// Aggregate all buckets within the time range
let requestCount = 0;
let errorCount = 0;
let totalDuration = 0;
let totalBytes = 0;
const statusCounts: Record<string, number> = { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 };
for (const bucket of this.trafficStats) {
if (bucket.timestamp >= cutoff) {
requestCount += bucket.requestCount;
errorCount += bucket.errorCount;
totalDuration += bucket.totalDuration;
totalBytes += bucket.totalSize;
for (const [status, count] of Object.entries(bucket.statusCounts)) {
statusCounts[status] = (statusCounts[status] || 0) + count;
}
}
}
// Calculate averages
const avgResponseTime = requestCount > 0
? (totalDuration / requestCount) / 1000 // Convert from microseconds to milliseconds
: 0;
const requestsPerMinute = requestCount / Math.max(minutes, 1);
const errorRate = requestCount > 0 ? (errorCount / requestCount) * 100 : 0;
return {
requestCount,
errorCount,
avgResponseTime: Math.round(avgResponseTime * 100) / 100, // Round to 2 decimal places
totalBytes,
statusCounts,
requestsPerMinute: Math.round(requestsPerMinute * 100) / 100,
errorRate: Math.round(errorRate * 100) / 100,
};
}
}

View File

@@ -8,7 +8,15 @@ import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
import type { Onebox } from './onebox.ts';
import type { IApiResponse, ICreateRegistryTokenRequest, IRegistryTokenView, TPlatformServiceType, IContainerStats } from '../types.ts';
import type {
IApiResponse,
ICreateRegistryTokenRequest,
IRegistryTokenView,
TPlatformServiceType,
IContainerStats,
IBackupScheduleCreate,
IBackupScheduleUpdate,
} from '../types.ts';
export class OneboxHttpServer {
private oneboxRef: Onebox;
@@ -297,16 +305,16 @@ export class OneboxHttpServer {
// Platform Services endpoints
} else if (path === '/api/platform-services' && method === 'GET') {
return await this.handleListPlatformServicesRequest();
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)$/) && method === 'GET') {
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)$/) && method === 'GET') {
const type = path.split('/').pop()! as TPlatformServiceType;
return await this.handleGetPlatformServiceRequest(type);
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)\/start$/) && method === 'POST') {
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)\/start$/) && method === 'POST') {
const type = path.split('/')[3] as TPlatformServiceType;
return await this.handleStartPlatformServiceRequest(type);
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)\/stop$/) && method === 'POST') {
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)\/stop$/) && method === 'POST') {
const type = path.split('/')[3] as TPlatformServiceType;
return await this.handleStopPlatformServiceRequest(type);
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)\/stats$/) && method === 'GET') {
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)\/stats$/) && method === 'GET') {
const type = path.split('/')[3] as TPlatformServiceType;
return await this.handleGetPlatformServiceStatsRequest(type);
} else if (path.match(/^\/api\/services\/[^/]+\/platform-resources$/) && method === 'GET') {
@@ -317,6 +325,54 @@ export class OneboxHttpServer {
return await this.handleGetNetworkTargetsRequest();
} else if (path === '/api/network/stats' && method === 'GET') {
return await this.handleGetNetworkStatsRequest();
} else if (path === '/api/network/traffic-stats' && method === 'GET') {
return await this.handleGetTrafficStatsRequest(new URL(req.url));
// 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/backups/import' && method === 'POST') {
return await this.handleImportBackupRequest(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();
// Backup Schedule endpoints
} else if (path === '/api/backup-schedules' && method === 'GET') {
return await this.handleListBackupSchedulesRequest();
} else if (path === '/api/backup-schedules' && method === 'POST') {
return await this.handleCreateBackupScheduleRequest(req);
} else if (path.match(/^\/api\/backup-schedules\/\d+$/) && method === 'GET') {
const scheduleId = Number(path.split('/').pop());
return await this.handleGetBackupScheduleRequest(scheduleId);
} else if (path.match(/^\/api\/backup-schedules\/\d+$/) && method === 'PUT') {
const scheduleId = Number(path.split('/').pop());
return await this.handleUpdateBackupScheduleRequest(scheduleId, req);
} else if (path.match(/^\/api\/backup-schedules\/\d+$/) && method === 'DELETE') {
const scheduleId = Number(path.split('/').pop());
return await this.handleDeleteBackupScheduleRequest(scheduleId);
} else if (path.match(/^\/api\/backup-schedules\/\d+\/trigger$/) && method === 'POST') {
const scheduleId = Number(path.split('/')[3]);
return await this.handleTriggerBackupScheduleRequest(scheduleId);
} else if (path.match(/^\/api\/services\/[^/]+\/backup-schedules$/) && method === 'GET') {
const serviceName = path.split('/')[3];
return await this.handleListServiceBackupSchedulesRequest(serviceName);
} else {
return this.jsonResponse({ success: false, error: 'Not found' }, 404);
}
@@ -1323,6 +1379,7 @@ export class OneboxHttpServer {
postgresql: 5432,
rabbitmq: 5672,
caddy: 80,
clickhouse: 8123,
};
return ports[type] || 0;
}
@@ -1364,6 +1421,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
*/
@@ -1983,6 +2071,626 @@ 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);
}
}
/**
* Import a backup from file upload or URL
*/
private async handleImportBackupRequest(req: Request): Promise<Response> {
try {
const contentType = req.headers.get('content-type') || '';
let filePath: string | null = null;
let newServiceName: string | undefined;
let tempFile = false;
if (contentType.includes('multipart/form-data')) {
// Handle file upload
const formData = await req.formData();
const file = formData.get('file');
newServiceName = formData.get('newServiceName')?.toString() || undefined;
if (!file || !(file instanceof File)) {
return this.jsonResponse({
success: false,
error: 'No file provided',
}, 400);
}
// Validate file extension
if (!file.name.endsWith('.tar.enc')) {
return this.jsonResponse({
success: false,
error: 'Invalid file format. Expected .tar.enc file',
}, 400);
}
// Save to temp location
const tempDir = './.nogit/temp-imports';
await Deno.mkdir(tempDir, { recursive: true });
filePath = `${tempDir}/${Date.now()}-${file.name}`;
tempFile = true;
const arrayBuffer = await file.arrayBuffer();
await Deno.writeFile(filePath, new Uint8Array(arrayBuffer));
logger.info(`Saved uploaded backup to ${filePath}`);
} else {
// Handle JSON body with URL
const body = await req.json();
const { url, newServiceName: serviceName } = body;
newServiceName = serviceName;
if (!url) {
return this.jsonResponse({
success: false,
error: 'URL is required when not uploading a file',
}, 400);
}
// Download from URL
const tempDir = './.nogit/temp-imports';
await Deno.mkdir(tempDir, { recursive: true });
const urlFilename = url.split('/').pop() || 'backup.tar.enc';
filePath = `${tempDir}/${Date.now()}-${urlFilename}`;
tempFile = true;
logger.info(`Downloading backup from ${url}...`);
const response = await fetch(url);
if (!response.ok) {
return this.jsonResponse({
success: false,
error: `Failed to download from URL: ${response.statusText}`,
}, 400);
}
const arrayBuffer = await response.arrayBuffer();
await Deno.writeFile(filePath, new Uint8Array(arrayBuffer));
logger.info(`Downloaded backup to ${filePath}`);
}
// Import using restoreBackup with mode='import'
const result = await this.oneboxRef.backupManager.restoreBackup(filePath, {
mode: 'import',
newServiceName,
overwriteExisting: false,
skipPlatformData: false,
});
// Clean up temp file
if (tempFile && filePath) {
try {
await Deno.remove(filePath);
} catch {
// Ignore cleanup errors
}
}
return this.jsonResponse({
success: true,
message: `Backup imported successfully as service '${result.service.name}'`,
data: {
service: result.service,
platformResourcesRestored: result.platformResourcesRestored,
warnings: result.warnings,
},
});
} catch (error) {
logger.error(`Failed to import backup: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to import 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);
}
}
// ============ Backup Schedule Endpoints ============
/**
* List all backup schedules
*/
private async handleListBackupSchedulesRequest(): Promise<Response> {
try {
const schedules = this.oneboxRef.backupScheduler.getAllSchedules();
return this.jsonResponse({ success: true, data: schedules });
} catch (error) {
logger.error(`Failed to list backup schedules: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to list backup schedules',
}, 500);
}
}
/**
* Create a new backup schedule
*/
private async handleCreateBackupScheduleRequest(req: Request): Promise<Response> {
try {
const body = await req.json() as IBackupScheduleCreate;
// Validate scope type
if (!body.scopeType) {
return this.jsonResponse({
success: false,
error: 'Scope type is required (all, pattern, or service)',
}, 400);
}
if (!['all', 'pattern', 'service'].includes(body.scopeType)) {
return this.jsonResponse({
success: false,
error: 'Invalid scope type. Must be: all, pattern, or service',
}, 400);
}
// Validate scope-specific requirements
if (body.scopeType === 'service' && !body.serviceName) {
return this.jsonResponse({
success: false,
error: 'Service name is required for service-specific schedules',
}, 400);
}
if (body.scopeType === 'pattern' && !body.scopePattern) {
return this.jsonResponse({
success: false,
error: 'Scope pattern is required for pattern-based schedules',
}, 400);
}
if (!body.cronExpression) {
return this.jsonResponse({
success: false,
error: 'Cron expression is required',
}, 400);
}
if (!body.retention) {
return this.jsonResponse({
success: false,
error: 'Retention policy is required',
}, 400);
}
// Validate retention policy
const { hourly, daily, weekly, monthly } = body.retention;
if (typeof hourly !== 'number' || typeof daily !== 'number' ||
typeof weekly !== 'number' || typeof monthly !== 'number') {
return this.jsonResponse({
success: false,
error: 'Retention policy must have hourly, daily, weekly, and monthly as numbers',
}, 400);
}
if (hourly < 0 || daily < 0 || weekly < 0 || monthly < 0) {
return this.jsonResponse({
success: false,
error: 'Retention values must be non-negative',
}, 400);
}
const schedule = await this.oneboxRef.backupScheduler.createSchedule(body);
// Build descriptive message based on scope type
let scopeDesc: string;
switch (body.scopeType) {
case 'all':
scopeDesc = 'all services';
break;
case 'pattern':
scopeDesc = `pattern '${body.scopePattern}'`;
break;
case 'service':
scopeDesc = `service '${body.serviceName}'`;
break;
}
return this.jsonResponse({
success: true,
message: `Backup schedule created for ${scopeDesc}`,
data: schedule,
});
} catch (error) {
logger.error(`Failed to create backup schedule: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to create backup schedule',
}, 500);
}
}
/**
* Get a specific backup schedule
*/
private async handleGetBackupScheduleRequest(scheduleId: number): Promise<Response> {
try {
const schedule = this.oneboxRef.backupScheduler.getScheduleById(scheduleId);
if (!schedule) {
return this.jsonResponse({ success: false, error: 'Backup schedule not found' }, 404);
}
return this.jsonResponse({ success: true, data: schedule });
} catch (error) {
logger.error(`Failed to get backup schedule ${scheduleId}: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to get backup schedule',
}, 500);
}
}
/**
* Update a backup schedule
*/
private async handleUpdateBackupScheduleRequest(scheduleId: number, req: Request): Promise<Response> {
try {
const body = await req.json() as IBackupScheduleUpdate;
// Validate retention policy if provided
if (body.retention) {
const { hourly, daily, weekly, monthly } = body.retention;
if (typeof hourly !== 'number' || typeof daily !== 'number' ||
typeof weekly !== 'number' || typeof monthly !== 'number') {
return this.jsonResponse({
success: false,
error: 'Retention policy must have hourly, daily, weekly, and monthly as numbers',
}, 400);
}
if (hourly < 0 || daily < 0 || weekly < 0 || monthly < 0) {
return this.jsonResponse({
success: false,
error: 'Retention values must be non-negative',
}, 400);
}
}
const schedule = await this.oneboxRef.backupScheduler.updateSchedule(scheduleId, body);
return this.jsonResponse({
success: true,
message: 'Backup schedule updated',
data: schedule,
});
} catch (error) {
logger.error(`Failed to update backup schedule ${scheduleId}: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to update backup schedule',
}, 500);
}
}
/**
* Delete a backup schedule
*/
private async handleDeleteBackupScheduleRequest(scheduleId: number): Promise<Response> {
try {
await this.oneboxRef.backupScheduler.deleteSchedule(scheduleId);
return this.jsonResponse({
success: true,
message: 'Backup schedule deleted',
});
} catch (error) {
logger.error(`Failed to delete backup schedule ${scheduleId}: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to delete backup schedule',
}, 500);
}
}
/**
* Trigger immediate backup for a schedule
*/
private async handleTriggerBackupScheduleRequest(scheduleId: number): Promise<Response> {
try {
await this.oneboxRef.backupScheduler.triggerBackup(scheduleId);
return this.jsonResponse({
success: true,
message: 'Backup triggered successfully',
});
} catch (error) {
logger.error(`Failed to trigger backup for schedule ${scheduleId}: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to trigger backup',
}, 500);
}
}
/**
* List backup schedules for a specific service
*/
private async handleListServiceBackupSchedulesRequest(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 schedules = this.oneboxRef.backupScheduler.getSchedulesForService(serviceName);
return this.jsonResponse({ success: true, data: schedules });
} catch (error) {
logger.error(`Failed to list backup schedules for service ${serviceName}: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to list backup schedules',
}, 500);
}
}
/**
* Helper to create JSON response
*/

View File

@@ -20,6 +20,8 @@ import { CertRequirementManager } from './cert-requirement-manager.ts';
import { RegistryManager } from './registry.ts';
import { PlatformServicesManager } from './platform-services/index.ts';
import { CaddyLogReceiver } from './caddy-log-receiver.ts';
import { BackupManager } from './backup-manager.ts';
import { BackupScheduler } from './backup-scheduler.ts';
export class Onebox {
public database: OneboxDatabase;
@@ -36,6 +38,8 @@ export class Onebox {
public registry: RegistryManager;
public platformServices: PlatformServicesManager;
public caddyLogReceiver: CaddyLogReceiver;
public backupManager: BackupManager;
public backupScheduler: BackupScheduler;
private initialized = false;
@@ -67,6 +71,12 @@ export class Onebox {
// Initialize Caddy log receiver
this.caddyLogReceiver = new CaddyLogReceiver(9999);
// Initialize Backup manager
this.backupManager = new BackupManager(this);
// Initialize Backup scheduler
this.backupScheduler = new BackupScheduler(this);
}
/**
@@ -161,6 +171,14 @@ export class Onebox {
// Start auto-update monitoring for registry services
this.services.startAutoUpdateMonitoring();
// Initialize Backup Scheduler (non-critical)
try {
await this.backupScheduler.init();
} catch (error) {
logger.warn('Backup scheduler initialization failed - scheduled backups will be disabled');
logger.warn(`Error: ${getErrorMessage(error)}`);
}
this.initialized = true;
logger.success('Onebox initialized successfully');
} catch (error) {
@@ -219,12 +237,51 @@ export class Onebox {
const runningServices = services.filter((s) => s.status === 'running').length;
const totalServices = services.length;
// Get platform services status
// Get platform services status with resource counts
const platformServices = this.platformServices.getAllPlatformServices();
const platformServicesStatus = platformServices.map((ps) => ({
type: ps.type,
status: ps.status,
}));
const providers = this.platformServices.getAllProviders();
const platformServicesStatus = providers.map((provider) => {
const service = platformServices.find((s) => s.type === provider.type);
// For Caddy, check actual runtime status since it starts without a DB record
let status = service?.status || 'not-deployed';
if (provider.type === 'caddy') {
status = proxyStatus.http.running ? 'running' : 'stopped';
}
// Count resources for this platform service
const resourceCount = service?.id
? this.database.getPlatformResourcesByPlatformService(service.id).length
: 0;
return {
type: provider.type,
displayName: provider.displayName,
status,
resourceCount,
};
});
// Get certificate health summary
const certificates = this.ssl.listCertificates();
const now = Date.now();
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
let validCount = 0;
let expiringCount = 0;
let expiredCount = 0;
const expiringDomains: { domain: string; daysRemaining: number }[] = [];
for (const cert of certificates) {
if (cert.expiryDate <= now) {
expiredCount++;
} else if (cert.expiryDate <= now + thirtyDaysMs) {
expiringCount++;
const daysRemaining = Math.floor((cert.expiryDate - now) / (24 * 60 * 60 * 1000));
expiringDomains.push({ domain: cert.domain, daysRemaining });
} else {
validCount++;
}
}
// Sort expiring domains by days remaining (ascending)
expiringDomains.sort((a, b) => a.daysRemaining - b.daysRemaining);
return {
docker: {
@@ -245,6 +302,12 @@ export class Onebox {
stopped: totalServices - runningServices,
},
platformServices: platformServicesStatus,
certificateHealth: {
valid: validCount,
expiringSoon: expiringCount,
expired: expiredCount,
expiringDomains: expiringDomains.slice(0, 5), // Top 5 expiring
},
};
} catch (error) {
logger.error(`Failed to get system status: ${getErrorMessage(error)}`);
@@ -287,6 +350,9 @@ export class Onebox {
try {
logger.info('Shutting down Onebox...');
// Stop backup scheduler
await this.backupScheduler.stop();
// Stop daemon if running
await this.daemon.stop();

View File

@@ -15,6 +15,7 @@ import type { IPlatformServiceProvider } from './providers/base.ts';
import { MongoDBProvider } from './providers/mongodb.ts';
import { MinioProvider } from './providers/minio.ts';
import { CaddyProvider } from './providers/caddy.ts';
import { ClickHouseProvider } from './providers/clickhouse.ts';
import { logger } from '../../logging.ts';
import { getErrorMessage } from '../../utils/error.ts';
import { credentialEncryption } from '../encryption.ts';
@@ -39,6 +40,7 @@ export class PlatformServicesManager {
this.registerProvider(new MongoDBProvider(this.oneboxRef));
this.registerProvider(new MinioProvider(this.oneboxRef));
this.registerProvider(new CaddyProvider(this.oneboxRef));
this.registerProvider(new ClickHouseProvider(this.oneboxRef));
logger.info(`Platform services manager initialized with ${this.providers.size} providers`);
}
@@ -275,6 +277,33 @@ export class PlatformServicesManager {
logger.success(`S3 storage provisioned for service '${service.name}'`);
}
// Provision ClickHouse if requested
if (requirements.clickhouse) {
logger.info(`Provisioning ClickHouse for service '${service.name}'...`);
// Ensure ClickHouse is running
const clickhouseService = await this.ensureRunning('clickhouse');
const provider = this.providers.get('clickhouse')!;
// Provision database
const result = await provider.provisionResource(service);
// Store resource record
const encryptedCreds = await credentialEncryption.encrypt(result.credentials);
this.oneboxRef.database.createPlatformResource({
platformServiceId: clickhouseService.id!,
serviceId: service.id!,
resourceType: result.type,
resourceName: result.name,
credentialsEncrypted: encryptedCreds,
createdAt: Date.now(),
});
// Merge env vars
Object.assign(allEnvVars, result.envVars);
logger.success(`ClickHouse provisioned for service '${service.name}'`);
}
return allEnvVars;
}

View File

@@ -0,0 +1,338 @@
/**
* ClickHouse Platform Service Provider
*/
import { BasePlatformServiceProvider } from './base.ts';
import type {
IService,
IPlatformResource,
IPlatformServiceConfig,
IProvisionedResource,
IEnvVarMapping,
TPlatformServiceType,
TPlatformResourceType,
} from '../../../types.ts';
import { logger } from '../../../logging.ts';
import { getErrorMessage } from '../../../utils/error.ts';
import { credentialEncryption } from '../../encryption.ts';
import type { Onebox } from '../../onebox.ts';
export class ClickHouseProvider extends BasePlatformServiceProvider {
readonly type: TPlatformServiceType = 'clickhouse';
readonly displayName = 'ClickHouse';
readonly resourceTypes: TPlatformResourceType[] = ['database'];
constructor(oneboxRef: Onebox) {
super(oneboxRef);
}
getDefaultConfig(): IPlatformServiceConfig {
return {
image: 'clickhouse/clickhouse-server:latest',
port: 8123, // HTTP interface
volumes: ['/var/lib/onebox/clickhouse:/var/lib/clickhouse'],
environment: {
CLICKHOUSE_DB: 'default',
// Password will be generated and stored encrypted
},
};
}
getEnvVarMappings(): IEnvVarMapping[] {
return [
{ envVar: 'CLICKHOUSE_HOST', credentialPath: 'host' },
{ envVar: 'CLICKHOUSE_PORT', credentialPath: 'port' },
{ envVar: 'CLICKHOUSE_HTTP_PORT', credentialPath: 'httpPort' },
{ envVar: 'CLICKHOUSE_DATABASE', credentialPath: 'database' },
{ envVar: 'CLICKHOUSE_USER', credentialPath: 'username' },
{ envVar: 'CLICKHOUSE_PASSWORD', credentialPath: 'password' },
{ envVar: 'CLICKHOUSE_URL', credentialPath: 'connectionUrl' },
];
}
async deployContainer(): Promise<string> {
const config = this.getDefaultConfig();
const containerName = this.getContainerName();
const dataDir = '/var/lib/onebox/clickhouse';
logger.info(`Deploying ClickHouse platform service as ${containerName}...`);
// Check if we have existing data and stored credentials
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
let adminCredentials: { username: string; password: string };
let dataExists = false;
// Check if data directory has existing ClickHouse data
// ClickHouse creates 'metadata' directory on first startup
try {
const stat = await Deno.stat(`${dataDir}/metadata`);
dataExists = stat.isDirectory;
logger.info(`ClickHouse data directory exists with metadata folder`);
} catch {
// metadata directory doesn't exist, this is a fresh install
dataExists = false;
}
if (dataExists && platformService?.adminCredentialsEncrypted) {
// Reuse existing credentials from database
logger.info('Reusing existing ClickHouse credentials (data directory already initialized)');
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
} else {
// Generate new credentials for fresh deployment
logger.info('Generating new ClickHouse admin credentials');
adminCredentials = {
username: 'default',
password: credentialEncryption.generatePassword(32),
};
// If data exists but we don't have credentials, we need to wipe the data
if (dataExists) {
logger.warn('ClickHouse data exists but no credentials in database - wiping data directory');
try {
await Deno.remove(dataDir, { recursive: true });
} catch (e) {
logger.error(`Failed to wipe ClickHouse data directory: ${getErrorMessage(e)}`);
throw new Error('Cannot deploy ClickHouse: data directory exists without credentials');
}
}
}
// Ensure data directory exists
try {
await Deno.mkdir(dataDir, { recursive: true });
} catch (e) {
// Directory might already exist
if (!(e instanceof Deno.errors.AlreadyExists)) {
logger.warn(`Could not create ClickHouse data directory: ${getErrorMessage(e)}`);
}
}
// Create container using Docker API
// ClickHouse uses environment variables for initial setup
const envVars = [
`CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1`,
`CLICKHOUSE_USER=${adminCredentials.username}`,
`CLICKHOUSE_PASSWORD=${adminCredentials.password}`,
];
const containerId = await this.oneboxRef.docker.createPlatformContainer({
name: containerName,
image: config.image,
port: config.port,
env: envVars,
volumes: config.volumes,
network: this.getNetworkName(),
exposePorts: [8123, 9000], // HTTP and native TCP ports
});
// Store encrypted admin credentials (only update if new or changed)
const encryptedCreds = await credentialEncryption.encrypt(adminCredentials);
if (platformService) {
this.oneboxRef.database.updatePlatformService(platformService.id!, {
containerId,
adminCredentialsEncrypted: encryptedCreds,
status: 'starting',
});
}
logger.success(`ClickHouse container created: ${containerId}`);
return containerId;
}
async stopContainer(containerId: string): Promise<void> {
logger.info(`Stopping ClickHouse container ${containerId}...`);
await this.oneboxRef.docker.stopContainer(containerId);
logger.success('ClickHouse container stopped');
}
async healthCheck(): Promise<boolean> {
try {
logger.info('ClickHouse health check: starting...');
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService) {
logger.info('ClickHouse health check: platform service not found in database');
return false;
}
if (!platformService.adminCredentialsEncrypted) {
logger.info('ClickHouse health check: no admin credentials stored');
return false;
}
if (!platformService.containerId) {
logger.info('ClickHouse health check: no container ID in database record');
return false;
}
logger.info(`ClickHouse health check: using container ID ${platformService.containerId.substring(0, 12)}...`);
// Use docker exec to run health check inside the container
// This avoids network issues with overlay networks
// Note: ClickHouse image has wget but not curl - use full path for reliability
const result = await this.oneboxRef.docker.execInContainer(
platformService.containerId,
['/usr/bin/wget', '-q', '-O', '-', 'http://localhost:8123/ping']
);
if (result.exitCode === 0) {
logger.info('ClickHouse health check: success');
return true;
} else {
logger.info(`ClickHouse health check failed: exit code ${result.exitCode}, stderr: ${result.stderr.substring(0, 200)}`);
return false;
}
} catch (error) {
logger.info(`ClickHouse health check exception: ${getErrorMessage(error)}`);
return false;
}
}
async provisionResource(userService: IService): Promise<IProvisionedResource> {
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) {
throw new Error('ClickHouse platform service not found or not configured');
}
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const containerName = this.getContainerName();
// Get container host port for connection from host (overlay network IPs not accessible from host)
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 8123);
if (!hostPort) {
throw new Error('Could not get ClickHouse container host port');
}
// Generate resource names and credentials
const dbName = this.generateResourceName(userService.name);
const username = this.generateResourceName(userService.name);
const password = credentialEncryption.generatePassword(32);
logger.info(`Provisioning ClickHouse database '${dbName}' for service '${userService.name}'...`);
// Connect to ClickHouse via localhost and the mapped host port
const baseUrl = `http://127.0.0.1:${hostPort}`;
// Create database
await this.executeQuery(
baseUrl,
adminCreds.username,
adminCreds.password,
`CREATE DATABASE IF NOT EXISTS ${dbName}`
);
logger.info(`Created ClickHouse database '${dbName}'`);
// Create user with access to this database
await this.executeQuery(
baseUrl,
adminCreds.username,
adminCreds.password,
`CREATE USER IF NOT EXISTS ${username} IDENTIFIED BY '${password}'`
);
logger.info(`Created ClickHouse user '${username}'`);
// Grant permissions on the database
await this.executeQuery(
baseUrl,
adminCreds.username,
adminCreds.password,
`GRANT ALL ON ${dbName}.* TO ${username}`
);
logger.info(`Granted permissions to user '${username}' on database '${dbName}'`);
logger.success(`ClickHouse database '${dbName}' provisioned with user '${username}'`);
// Build the credentials and env vars
const credentials: Record<string, string> = {
host: containerName,
port: '9000', // Native TCP port
httpPort: '8123',
database: dbName,
username,
password,
connectionUrl: `http://${username}:${password}@${containerName}:8123/?database=${dbName}`,
};
// Map credentials to env vars
const envVars: Record<string, string> = {};
for (const mapping of this.getEnvVarMappings()) {
if (credentials[mapping.credentialPath]) {
envVars[mapping.envVar] = credentials[mapping.credentialPath];
}
}
return {
type: 'database',
name: dbName,
credentials,
envVars,
};
}
async deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void> {
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) {
throw new Error('ClickHouse platform service not found or not configured');
}
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
// Get container host port for connection from host (overlay network IPs not accessible from host)
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 8123);
if (!hostPort) {
throw new Error('Could not get ClickHouse container host port');
}
logger.info(`Deprovisioning ClickHouse database '${resource.resourceName}'...`);
const baseUrl = `http://127.0.0.1:${hostPort}`;
try {
// Drop the user
try {
await this.executeQuery(
baseUrl,
adminCreds.username,
adminCreds.password,
`DROP USER IF EXISTS ${credentials.username}`
);
logger.info(`Dropped ClickHouse user '${credentials.username}'`);
} catch (e) {
logger.warn(`Could not drop ClickHouse user: ${getErrorMessage(e)}`);
}
// Drop the database
await this.executeQuery(
baseUrl,
adminCreds.username,
adminCreds.password,
`DROP DATABASE IF EXISTS ${resource.resourceName}`
);
logger.success(`ClickHouse database '${resource.resourceName}' dropped`);
} catch (e) {
logger.error(`Failed to deprovision ClickHouse database: ${getErrorMessage(e)}`);
throw e;
}
}
/**
* Execute a ClickHouse SQL query via HTTP interface
*/
private async executeQuery(
baseUrl: string,
username: string,
password: string,
query: string
): Promise<string> {
const url = `${baseUrl}/?user=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
const response = await fetch(url, {
method: 'POST',
body: query,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`ClickHouse query failed: ${errorText}`);
}
return await response.text();
}
}

View File

@@ -49,10 +49,11 @@ export class OneboxServicesManager {
// Build platform requirements
const platformRequirements: IPlatformRequirements | undefined =
(options.enableMongoDB || options.enableS3)
(options.enableMongoDB || options.enableS3 || options.enableClickHouse)
? {
mongodb: options.enableMongoDB,
s3: options.enableS3,
clickhouse: options.enableClickHouse,
}
: undefined;

View File

@@ -18,6 +18,9 @@ import type {
IDomain,
ICertificate,
ICertRequirement,
IBackup,
IBackupSchedule,
IBackupScheduleUpdate,
} from '../types.ts';
import type { TBindValue } from './types.ts';
import { logger } from '../logging.ts';
@@ -31,6 +34,7 @@ import {
AuthRepository,
MetricsRepository,
PlatformRepository,
BackupRepository,
} from './repositories/index.ts';
export class OneboxDatabase {
@@ -44,6 +48,7 @@ export class OneboxDatabase {
private authRepo!: AuthRepository;
private metricsRepo!: MetricsRepository;
private platformRepo!: PlatformRepository;
private backupRepo!: BackupRepository;
constructor(dbPath = './.nogit/onebox.db') {
this.dbPath = dbPath;
@@ -76,6 +81,7 @@ export class OneboxDatabase {
this.authRepo = new AuthRepository(queryFn);
this.metricsRepo = new MetricsRepository(queryFn);
this.platformRepo = new PlatformRepository(queryFn);
this.backupRepo = new BackupRepository(queryFn);
} catch (error) {
logger.error(`Failed to initialize database: ${getErrorMessage(error)}`);
throw error;
@@ -705,6 +711,214 @@ export class OneboxDatabase {
this.setMigrationVersion(8);
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');
}
// Migration 10: Backup schedules table and extend backups table
const version10 = this.getMigrationVersion();
if (version10 < 10) {
logger.info('Running migration 10: Creating backup schedules table...');
// Create backup_schedules table
this.query(`
CREATE TABLE backup_schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL,
service_name TEXT NOT NULL,
cron_expression TEXT NOT NULL,
retention_tier TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
last_run_at REAL,
next_run_at REAL,
last_status TEXT,
last_error TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_service ON backup_schedules(service_id)');
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_enabled ON backup_schedules(enabled)');
// Extend backups table with retention_tier and schedule_id columns
this.query('ALTER TABLE backups ADD COLUMN retention_tier TEXT');
this.query('ALTER TABLE backups ADD COLUMN schedule_id INTEGER REFERENCES backup_schedules(id) ON DELETE SET NULL');
this.setMigrationVersion(10);
logger.success('Migration 10 completed: Backup schedules table created');
}
// Migration 11: Add scope columns for global/pattern backup schedules
const version11 = this.getMigrationVersion();
if (version11 < 11) {
logger.info('Running migration 11: Adding scope columns to backup_schedules...');
// Recreate backup_schedules table with nullable service_id/service_name and new scope columns
this.query(`
CREATE TABLE backup_schedules_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope_type TEXT NOT NULL DEFAULT 'service',
scope_pattern TEXT,
service_id INTEGER,
service_name TEXT,
cron_expression TEXT NOT NULL,
retention_tier TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
last_run_at REAL,
next_run_at REAL,
last_status TEXT,
last_error TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
// Copy existing schedules (all are service-specific)
this.query(`
INSERT INTO backup_schedules_new (
id, scope_type, scope_pattern, service_id, service_name, cron_expression,
retention_tier, enabled, last_run_at, next_run_at, last_status, last_error,
created_at, updated_at
)
SELECT
id, 'service', NULL, service_id, service_name, cron_expression,
retention_tier, enabled, last_run_at, next_run_at, last_status, last_error,
created_at, updated_at
FROM backup_schedules
`);
this.query('DROP TABLE backup_schedules');
this.query('ALTER TABLE backup_schedules_new RENAME TO backup_schedules');
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_service ON backup_schedules(service_id)');
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_enabled ON backup_schedules(enabled)');
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_scope ON backup_schedules(scope_type)');
this.setMigrationVersion(11);
logger.success('Migration 11 completed: Scope columns added to backup_schedules');
}
// Migration 12: GFS retention policy - replace retention_tier with per-tier retention counts
const version12 = this.getMigrationVersion();
if (version12 < 12) {
logger.info('Running migration 12: Updating backup system for GFS retention policy...');
// Recreate backup_schedules table with new retention columns
this.query(`
CREATE TABLE backup_schedules_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope_type TEXT NOT NULL DEFAULT 'service',
scope_pattern TEXT,
service_id INTEGER,
service_name TEXT,
cron_expression TEXT NOT NULL,
retention_hourly INTEGER NOT NULL DEFAULT 0,
retention_daily INTEGER NOT NULL DEFAULT 7,
retention_weekly INTEGER NOT NULL DEFAULT 4,
retention_monthly INTEGER NOT NULL DEFAULT 12,
enabled INTEGER NOT NULL DEFAULT 1,
last_run_at REAL,
next_run_at REAL,
last_status TEXT,
last_error TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
// Migrate existing data - convert old retention_tier to new format
// daily -> D:7, weekly -> W:4, monthly -> M:12, yearly -> M:12 (yearly becomes long monthly retention)
this.query(`
INSERT INTO backup_schedules_new (
id, scope_type, scope_pattern, service_id, service_name, cron_expression,
retention_hourly, retention_daily, retention_weekly, retention_monthly,
enabled, last_run_at, next_run_at, last_status, last_error, created_at, updated_at
)
SELECT
id, scope_type, scope_pattern, service_id, service_name, cron_expression,
0, -- retention_hourly
CASE WHEN retention_tier = 'daily' THEN 7 ELSE 0 END,
CASE WHEN retention_tier IN ('daily', 'weekly') THEN 4 ELSE 0 END,
CASE WHEN retention_tier IN ('daily', 'weekly', 'monthly') THEN 12
WHEN retention_tier = 'yearly' THEN 24 ELSE 12 END,
enabled, last_run_at, next_run_at, last_status, last_error, created_at, updated_at
FROM backup_schedules
`);
this.query('DROP TABLE backup_schedules');
this.query('ALTER TABLE backup_schedules_new RENAME TO backup_schedules');
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_service ON backup_schedules(service_id)');
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_enabled ON backup_schedules(enabled)');
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_scope ON backup_schedules(scope_type)');
// Recreate backups table without retention_tier column
this.query(`
CREATE TABLE backups_new (
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,
schedule_id INTEGER REFERENCES backup_schedules(id) ON DELETE SET NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
this.query(`
INSERT INTO backups_new (
id, service_id, service_name, filename, size_bytes, created_at,
includes_image, platform_resources, checksum, schedule_id
)
SELECT
id, service_id, service_name, filename, size_bytes, created_at,
includes_image, platform_resources, checksum, schedule_id
FROM backups
`);
this.query('DROP TABLE backups');
this.query('ALTER TABLE backups_new RENAME TO backups');
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.query('CREATE INDEX IF NOT EXISTS idx_backups_schedule ON backups(schedule_id)');
this.setMigrationVersion(12);
logger.success('Migration 12 completed: GFS retention policy schema updated');
}
} catch (error) {
logger.error(`Migration failed: ${getErrorMessage(error)}`);
if (error instanceof Error && error.stack) {
@@ -1078,4 +1292,68 @@ export class OneboxDatabase {
deletePlatformResourcesByService(serviceId: number): void {
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);
}
getBackupsBySchedule(scheduleId: number): IBackup[] {
return this.backupRepo.getBySchedule(scheduleId);
}
// ============ Backup Schedules (delegated to repository) ============
createBackupSchedule(schedule: Omit<IBackupSchedule, 'id'>): IBackupSchedule {
return this.backupRepo.createSchedule(schedule);
}
getBackupScheduleById(id: number): IBackupSchedule | null {
return this.backupRepo.getScheduleById(id);
}
getBackupSchedulesByService(serviceId: number): IBackupSchedule[] {
return this.backupRepo.getSchedulesByService(serviceId);
}
getEnabledBackupSchedules(): IBackupSchedule[] {
return this.backupRepo.getEnabledSchedules();
}
getAllBackupSchedules(): IBackupSchedule[] {
return this.backupRepo.getAllSchedules();
}
updateBackupSchedule(id: number, updates: IBackupScheduleUpdate & { lastRunAt?: number; nextRunAt?: number; lastStatus?: 'success' | 'failed' | null; lastError?: string | null }): void {
this.backupRepo.updateSchedule(id, updates);
}
deleteBackupSchedule(id: number): void {
this.backupRepo.deleteSchedule(id);
}
deleteBackupSchedulesByService(serviceId: number): void {
this.backupRepo.deleteSchedulesByService(serviceId);
}
}

View File

@@ -0,0 +1,249 @@
/**
* Backup Repository
* Handles CRUD operations for backups and backup_schedules tables
*/
import { BaseRepository } from '../base.repository.ts';
import type {
IBackup,
IBackupSchedule,
IBackupScheduleUpdate,
TPlatformServiceType,
TBackupScheduleScope,
IRetentionPolicy,
} from '../../types.ts';
export class BackupRepository extends BaseRepository {
// ============ Backup CRUD ============
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, schedule_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
backup.serviceId,
backup.serviceName,
backup.filename,
backup.sizeBytes,
backup.createdAt,
backup.includesImage ? 1 : 0,
JSON.stringify(backup.platformResources),
backup.checksum,
backup.scheduleId ?? null,
]
);
// 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]);
}
getBySchedule(scheduleId: number): IBackup[] {
const rows = this.query(
'SELECT * FROM backups WHERE schedule_id = ? ORDER BY created_at DESC',
[scheduleId]
);
return rows.map((row) => this.rowToBackup(row));
}
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),
scheduleId: row.schedule_id ? Number(row.schedule_id) : undefined,
};
}
// ============ Backup Schedule CRUD ============
createSchedule(schedule: Omit<IBackupSchedule, 'id'>): IBackupSchedule {
const now = Date.now();
this.query(
`INSERT INTO backup_schedules (
scope_type, scope_pattern, service_id, service_name, cron_expression,
retention_hourly, retention_daily, retention_weekly, retention_monthly,
enabled, last_run_at, next_run_at, last_status, last_error, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
schedule.scopeType,
schedule.scopePattern ?? null,
schedule.serviceId ?? null,
schedule.serviceName ?? null,
schedule.cronExpression,
schedule.retention.hourly,
schedule.retention.daily,
schedule.retention.weekly,
schedule.retention.monthly,
schedule.enabled ? 1 : 0,
schedule.lastRunAt,
schedule.nextRunAt,
schedule.lastStatus,
schedule.lastError,
now,
now,
]
);
// Get the created schedule by looking for the most recent one with matching scope
const rows = this.query(
'SELECT * FROM backup_schedules WHERE scope_type = ? AND cron_expression = ? ORDER BY id DESC LIMIT 1',
[schedule.scopeType, schedule.cronExpression]
);
return this.rowToSchedule(rows[0]);
}
getScheduleById(id: number): IBackupSchedule | null {
const rows = this.query('SELECT * FROM backup_schedules WHERE id = ?', [id]);
return rows.length > 0 ? this.rowToSchedule(rows[0]) : null;
}
getSchedulesByService(serviceId: number): IBackupSchedule[] {
const rows = this.query(
'SELECT * FROM backup_schedules WHERE service_id = ? ORDER BY created_at DESC',
[serviceId]
);
return rows.map((row) => this.rowToSchedule(row));
}
getEnabledSchedules(): IBackupSchedule[] {
const rows = this.query(
'SELECT * FROM backup_schedules WHERE enabled = 1 ORDER BY next_run_at ASC'
);
return rows.map((row) => this.rowToSchedule(row));
}
getAllSchedules(): IBackupSchedule[] {
const rows = this.query('SELECT * FROM backup_schedules ORDER BY created_at DESC');
return rows.map((row) => this.rowToSchedule(row));
}
updateSchedule(id: number, updates: IBackupScheduleUpdate & { lastRunAt?: number; nextRunAt?: number; lastStatus?: 'success' | 'failed' | null; lastError?: string | null }): void {
const setClauses: string[] = [];
const params: (string | number | null)[] = [];
if (updates.cronExpression !== undefined) {
setClauses.push('cron_expression = ?');
params.push(updates.cronExpression);
}
if (updates.retention !== undefined) {
setClauses.push('retention_hourly = ?');
params.push(updates.retention.hourly);
setClauses.push('retention_daily = ?');
params.push(updates.retention.daily);
setClauses.push('retention_weekly = ?');
params.push(updates.retention.weekly);
setClauses.push('retention_monthly = ?');
params.push(updates.retention.monthly);
}
if (updates.enabled !== undefined) {
setClauses.push('enabled = ?');
params.push(updates.enabled ? 1 : 0);
}
if (updates.lastRunAt !== undefined) {
setClauses.push('last_run_at = ?');
params.push(updates.lastRunAt);
}
if (updates.nextRunAt !== undefined) {
setClauses.push('next_run_at = ?');
params.push(updates.nextRunAt);
}
if (updates.lastStatus !== undefined) {
setClauses.push('last_status = ?');
params.push(updates.lastStatus);
}
if (updates.lastError !== undefined) {
setClauses.push('last_error = ?');
params.push(updates.lastError);
}
if (setClauses.length === 0) return;
setClauses.push('updated_at = ?');
params.push(Date.now());
params.push(id);
this.query(`UPDATE backup_schedules SET ${setClauses.join(', ')} WHERE id = ?`, params);
}
deleteSchedule(id: number): void {
this.query('DELETE FROM backup_schedules WHERE id = ?', [id]);
}
deleteSchedulesByService(serviceId: number): void {
this.query('DELETE FROM backup_schedules WHERE service_id = ?', [serviceId]);
}
private rowToSchedule(row: any): IBackupSchedule {
return {
id: Number(row.id),
scopeType: (String(row.scope_type) || 'service') as TBackupScheduleScope,
scopePattern: row.scope_pattern ? String(row.scope_pattern) : undefined,
serviceId: row.service_id ? Number(row.service_id) : undefined,
serviceName: row.service_name ? String(row.service_name) : undefined,
cronExpression: String(row.cron_expression),
retention: {
hourly: Number(row.retention_hourly ?? 0),
daily: Number(row.retention_daily ?? 7),
weekly: Number(row.retention_weekly ?? 4),
monthly: Number(row.retention_monthly ?? 12),
} as IRetentionPolicy,
enabled: Boolean(row.enabled),
lastRunAt: row.last_run_at ? Number(row.last_run_at) : null,
nextRunAt: row.next_run_at ? Number(row.next_run_at) : null,
lastStatus: row.last_status ? (String(row.last_status) as 'success' | 'failed') : null,
lastError: row.last_error ? String(row.last_error) : null,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
};
}
}

View File

@@ -8,3 +8,4 @@ export { CertificateRepository } from './certificate.repository.ts';
export { AuthRepository } from './auth.repository.ts';
export { MetricsRepository } from './metrics.repository.ts';
export { PlatformRepository } from './platform.repository.ts';
export { BackupRepository } from './backup.repository.ts';

View File

@@ -119,6 +119,10 @@ export class ServiceRepository extends BaseRepository {
fields.push('platform_requirements = ?');
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 = ?');
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,
imageDigest: row.image_digest ? String(row.image_digest) : undefined,
platformRequirements,
includeImageInBackup: row.include_image_in_backup !== undefined
? Boolean(row.include_image_in_backup)
: true, // Default to true
};
}
}

View File

@@ -41,6 +41,10 @@ export { smartregistry };
import * as smarts3 from '@push.rocks/smarts3';
export { smarts3 };
// Task scheduling and cron jobs
import * as taskbuffer from '@push.rocks/taskbuffer';
export { taskbuffer };
// Crypto utilities (for password hashing, encryption)
import * as bcrypt from 'https://deno.land/x/bcrypt@v0.4.1/mod.ts';
export { bcrypt };

View File

@@ -23,6 +23,8 @@ export interface IService {
imageDigest?: string;
// Platform service requirements
platformRequirements?: IPlatformRequirements;
// Backup settings
includeImageInBackup?: boolean;
}
// Registry types
@@ -73,7 +75,7 @@ export interface ITokenCreatedResponse {
}
// Platform service types
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy';
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy' | 'clickhouse';
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
export type TPlatformServiceStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
@@ -110,6 +112,7 @@ export interface IPlatformResource {
export interface IPlatformRequirements {
mongodb?: boolean;
s3?: boolean;
clickhouse?: boolean;
}
export interface IProvisionedResource {
@@ -287,6 +290,7 @@ export interface IServiceDeployOptions {
// Platform service requirements
enableMongoDB?: boolean;
enableS3?: boolean;
enableClickHouse?: boolean;
}
// HTTP API request/response types
@@ -315,3 +319,128 @@ export interface ICliArgs {
_: string[];
[key: string]: unknown;
}
// Backup types
export type TBackupRestoreMode = 'restore' | 'import' | 'clone';
// Retention policy for GFS (Grandfather-Father-Son) time-window based retention
export interface IRetentionPolicy {
hourly: number; // 0 = disabled, else keep up to N backups from last 24h
daily: number; // Keep 1 backup per day for last N days
weekly: number; // Keep 1 backup per week for last N weeks
monthly: number; // Keep 1 backup per month for last N months
}
// Default retention presets
export const RETENTION_PRESETS = {
standard: { hourly: 0, daily: 7, weekly: 4, monthly: 12 },
frequent: { hourly: 24, daily: 7, weekly: 4, monthly: 12 },
minimal: { hourly: 0, daily: 3, weekly: 2, monthly: 6 },
longterm: { hourly: 0, daily: 14, weekly: 8, monthly: 24 },
} as const;
export type TRetentionPreset = keyof typeof RETENTION_PRESETS | 'custom';
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;
// Scheduled backup fields
scheduleId?: number; // Links backup to its schedule for retention
}
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[];
}
// Backup scheduling types (GFS retention scheme)
export type TBackupScheduleScope = 'all' | 'pattern' | 'service';
export interface IBackupSchedule {
id?: number;
scopeType: TBackupScheduleScope;
scopePattern?: string; // Glob pattern for 'pattern' scope type
serviceId?: number; // Only for 'service' scope type
serviceName?: string; // Only for 'service' scope type
cronExpression: string;
retention: IRetentionPolicy; // Per-tier retention counts
enabled: boolean;
lastRunAt: number | null;
nextRunAt: number | null;
lastStatus: 'success' | 'failed' | null;
lastError: string | null;
createdAt: number;
updatedAt: number;
}
export interface IBackupScheduleCreate {
scopeType: TBackupScheduleScope;
scopePattern?: string; // Required for 'pattern' scope type
serviceName?: string; // Required for 'service' scope type
cronExpression: string;
retention: IRetentionPolicy;
enabled?: boolean;
}
export interface IBackupScheduleUpdate {
cronExpression?: string;
retention?: IRetentionPolicy;
enabled?: boolean;
}
// Backup creation options (for scheduled backups)
export interface IBackupCreateOptions {
scheduleId?: number;
}

View File

@@ -24,6 +24,14 @@ import {
INetworkStats,
IContainerStats,
IMetric,
ITrafficStats,
IBackup,
IRestoreOptions,
IRestoreResult,
IBackupPasswordStatus,
IBackupSchedule,
IBackupScheduleCreate,
IBackupScheduleUpdate,
} from '../types/api.types';
@Injectable({ providedIn: 'root' })
@@ -204,4 +212,123 @@ export class ApiService {
async getNetworkStats(): Promise<IApiResponse<INetworkStats>> {
return firstValueFrom(this.http.get<IApiResponse<INetworkStats>>('/api/network/stats'));
}
async getTrafficStats(minutes?: number): Promise<IApiResponse<ITrafficStats>> {
const params = minutes ? `?minutes=${minutes}` : '';
return firstValueFrom(this.http.get<IApiResponse<ITrafficStats>>(`/api/network/traffic-stats${params}`));
}
// 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 downloadBackup(backupId: number, filename: string): Promise<void> {
const token = localStorage.getItem('onebox_token');
const response = await fetch(`/api/backups/${backupId}/download`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!response.ok) {
throw new Error('Download failed');
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async importBackupFromFile(file: File, newServiceName?: string): Promise<IApiResponse<IRestoreResult>> {
const formData = new FormData();
formData.append('file', file);
if (newServiceName) {
formData.append('newServiceName', newServiceName);
}
return firstValueFrom(
this.http.post<IApiResponse<IRestoreResult>>('/api/backups/import', formData)
);
}
async importBackupFromUrl(url: string, newServiceName?: string): Promise<IApiResponse<IRestoreResult>> {
return firstValueFrom(
this.http.post<IApiResponse<IRestoreResult>>('/api/backups/import', {
url,
newServiceName,
})
);
}
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')
);
}
// Backup Schedules
async getBackupSchedules(): Promise<IApiResponse<IBackupSchedule[]>> {
return firstValueFrom(this.http.get<IApiResponse<IBackupSchedule[]>>('/api/backup-schedules'));
}
async getBackupSchedule(scheduleId: number): Promise<IApiResponse<IBackupSchedule>> {
return firstValueFrom(this.http.get<IApiResponse<IBackupSchedule>>(`/api/backup-schedules/${scheduleId}`));
}
async createBackupSchedule(data: IBackupScheduleCreate): Promise<IApiResponse<IBackupSchedule>> {
return firstValueFrom(this.http.post<IApiResponse<IBackupSchedule>>('/api/backup-schedules', data));
}
async updateBackupSchedule(scheduleId: number, data: IBackupScheduleUpdate): Promise<IApiResponse<IBackupSchedule>> {
return firstValueFrom(this.http.put<IApiResponse<IBackupSchedule>>(`/api/backup-schedules/${scheduleId}`, data));
}
async deleteBackupSchedule(scheduleId: number): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/backup-schedules/${scheduleId}`));
}
async triggerBackupSchedule(scheduleId: number): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/backup-schedules/${scheduleId}/trigger`, {}));
}
async getServiceBackupSchedules(serviceName: string): Promise<IApiResponse<IBackupSchedule[]>> {
return firstValueFrom(this.http.get<IApiResponse<IBackupSchedule[]>>(`/api/services/${serviceName}/backup-schedules`));
}
}

View File

@@ -16,13 +16,14 @@ export interface ILoginResponse {
}
// Platform Service Types (defined early for use in ISystemStatus)
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy';
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy' | 'clickhouse';
export type TPlatformServiceStatus = 'not-deployed' | 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
export interface IPlatformRequirements {
mongodb?: boolean;
s3?: boolean;
clickhouse?: boolean;
}
export interface IService {
@@ -56,6 +57,7 @@ export interface IServiceCreate {
autoUpdateOnPush?: boolean;
enableMongoDB?: boolean;
enableS3?: boolean;
enableClickHouse?: boolean; // ClickHouse analytics database
}
export interface IServiceUpdate {
@@ -79,7 +81,18 @@ export interface ISystemStatus {
dns: { configured: boolean };
ssl: { configured: boolean; certificateCount: number };
services: { total: number; running: number; stopped: number };
platformServices: Array<{ type: TPlatformServiceType; status: TPlatformServiceStatus }>;
platformServices: Array<{
type: TPlatformServiceType;
displayName: string;
status: TPlatformServiceStatus;
resourceCount: number;
}>;
certificateHealth: {
valid: number;
expiringSoon: number;
expired: number;
expiringDomains: Array<{ domain: string; daysRemaining: number }>;
};
}
export interface IDomain {
@@ -320,3 +333,104 @@ export interface IStatsUpdateMessage {
stats: IContainerStats;
timestamp: number;
}
// Traffic stats from Caddy access logs
export interface ITrafficStats {
requestCount: number;
errorCount: number;
avgResponseTime: number; // milliseconds
totalBytes: number;
statusCounts: Record<string, number>; // '2xx', '3xx', '4xx', '5xx'
requestsPerMinute: number;
errorRate: number; // percentage
}
// 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;
}
// Backup Schedule Types
export type TBackupScheduleScope = 'all' | 'pattern' | 'service';
// Retention policy for GFS (Grandfather-Father-Son) time-window based retention
export interface IRetentionPolicy {
hourly: number; // 0 = disabled, else keep up to N backups from last 24h
daily: number; // Keep 1 backup per day for last N days
weekly: number; // Keep 1 backup per week for last N weeks
monthly: number; // Keep 1 backup per month for last N months
}
// Default retention presets
export const RETENTION_PRESETS = {
standard: { hourly: 0, daily: 7, weekly: 4, monthly: 12 },
frequent: { hourly: 24, daily: 7, weekly: 4, monthly: 12 },
minimal: { hourly: 0, daily: 3, weekly: 2, monthly: 6 },
longterm: { hourly: 0, daily: 14, weekly: 8, monthly: 24 },
} as const;
export type TRetentionPreset = keyof typeof RETENTION_PRESETS | 'custom';
export interface IBackupSchedule {
id?: number;
scopeType: TBackupScheduleScope;
scopePattern?: string; // Glob pattern for 'pattern' scope type
serviceId?: number; // Only for 'service' scope type
serviceName?: string; // Only for 'service' scope type
cronExpression: string;
retention: IRetentionPolicy; // Per-tier retention counts
enabled: boolean;
lastRunAt: number | null;
nextRunAt: number | null;
lastStatus: 'success' | 'failed' | null;
lastError: string | null;
createdAt: number;
updatedAt: number;
}
export interface IBackupScheduleCreate {
scopeType: TBackupScheduleScope;
scopePattern?: string; // Required for 'pattern' scope type
serviceName?: string; // Required for 'service' scope type
cronExpression: string;
retention: IRetentionPolicy;
enabled?: boolean;
}
export interface IBackupScheduleUpdate {
cronExpression?: string;
retention?: IRetentionPolicy;
enabled?: boolean;
}
// Updated IBackup with schedule fields
export interface IBackupWithSchedule extends IBackup {
scheduleId?: number;
}

View 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: [],
};
}

View File

@@ -14,6 +14,10 @@ import {
import { ButtonComponent } from '../../ui/button/button.component';
import { BadgeComponent } from '../../ui/badge/badge.component';
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
import { TrafficCardComponent } from './traffic-card.component';
import { PlatformServicesCardComponent } from './platform-services-card.component';
import { CertificatesCardComponent } from './certificates-card.component';
import { ResourceUsageCardComponent } from './resource-usage-card.component';
@Component({
selector: 'app-dashboard',
@@ -28,6 +32,10 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
ButtonComponent,
BadgeComponent,
SkeletonComponent,
TrafficCardComponent,
PlatformServicesCardComponent,
CertificatesCardComponent,
ResourceUsageCardComponent,
],
template: `
<div class="space-y-6">
@@ -63,7 +71,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
}
</div>
} @else if (status()) {
<!-- Stats Grid -->
<!-- Row 1: Key Stats Grid -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<ui-card>
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
@@ -117,8 +125,23 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
</ui-card>
</div>
<!-- System Status -->
<!-- Row 2: Resource Usage (full width) -->
<app-resource-usage-card />
<!-- Row 3: Traffic & Platform Services (2-column) -->
<div class="grid gap-4 md:grid-cols-2">
<!-- Traffic Overview -->
<app-traffic-card />
<!-- Platform Services Status -->
<app-platform-services-card [services]="status()!.platformServices" />
</div>
<!-- Row 4: Certificates & System Status (3-column) -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<!-- Certificates Health -->
<app-certificates-card [health]="status()!.certificateHealth" />
<!-- Reverse Proxy -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
@@ -139,56 +162,42 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
</ui-badge>
</div>
<div class="flex items-center justify-between">
<span class="text-sm">Certificates</span>
<span class="text-sm font-medium">{{ status()!.reverseProxy.https.certificates }}</span>
<span class="text-sm">Routes</span>
<span class="text-sm font-medium">{{ status()!.reverseProxy.routes }}</span>
</div>
</ui-card-content>
</ui-card>
<!-- DNS -->
<!-- DNS & SSL Combined -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>DNS</ui-card-title>
<ui-card-description>DNS configuration status</ui-card-description>
<ui-card-title>DNS & SSL</ui-card-title>
<ui-card-description>Configuration status</ui-card-description>
</ui-card-header>
<ui-card-content>
<ui-card-content class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm">Cloudflare</span>
<span class="text-sm">Cloudflare DNS</span>
<ui-badge [variant]="status()!.dns.configured ? 'success' : 'secondary'">
{{ status()!.dns.configured ? 'Configured' : 'Not configured' }}
</ui-badge>
</div>
</ui-card-content>
</ui-card>
<!-- SSL -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>SSL/TLS</ui-card-title>
<ui-card-description>Certificate management</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm">ACME</span>
<span class="text-sm">ACME (Let's Encrypt)</span>
<ui-badge [variant]="status()!.ssl.configured ? 'success' : 'secondary'">
{{ status()!.ssl.configured ? 'Configured' : 'Not configured' }}
</ui-badge>
</div>
<div class="flex items-center justify-between">
<span class="text-sm">Certificates</span>
<span class="text-sm font-medium">{{ status()!.ssl.certificateCount }} managed</span>
</div>
</ui-card-content>
</ui-card>
</div>
<!-- Quick Actions -->
<!-- Row 5: Quick Actions -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Quick Actions</ui-card-title>
<ui-card-description>Common tasks and shortcuts</ui-card-description>
</ui-card-header>
<ui-card-content class="flex gap-4">
<ui-card-content class="flex flex-wrap gap-4">
<a routerLink="/services/create">
<button uiButton>
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
@@ -200,6 +209,9 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
<a routerLink="/services">
<button uiButton variant="outline">View All Services</button>
</a>
<a routerLink="/platform-services">
<button uiButton variant="outline">Platform Services</button>
</a>
<a routerLink="/network">
<button uiButton variant="outline">Manage Domains</button>
</a>

View File

@@ -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';
}
}
}

View 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';
}
}

View 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;
}
}

View File

@@ -56,7 +56,7 @@ import { AlertComponent, AlertDescriptionComponent } from '../../ui/alert/alert.
<ui-card class="w-full max-w-md">
<ui-card-header class="text-center">
<div class="mx-auto mb-4">
<div class="flex justify-center mb-4">
<svg class="h-12 w-12 text-primary" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
</svg>

File diff suppressed because it is too large Load Diff

View File

@@ -357,6 +357,7 @@ export class PlatformServiceDetailComponent implements OnInit, OnDestroy {
postgresql: 'PostgreSQL is a powerful, open-source object-relational database system with over 35 years of active development.',
rabbitmq: 'RabbitMQ is a message broker that enables applications to communicate with each other using messages through queues.',
caddy: 'Caddy is a powerful, enterprise-ready, open-source web server with automatic HTTPS. It serves as the reverse proxy for Onebox.',
clickhouse: 'ClickHouse is a fast, open-source columnar database management system optimized for real-time analytics and data warehousing.',
};
return descriptions[type] || 'A platform service managed by Onebox.';
}

View File

@@ -215,9 +215,20 @@ interface EnvVar {
<p class="text-xs text-muted-foreground">A dedicated bucket will be created and credentials injected as S3_* and AWS_* env vars</p>
</div>
</div>
<div class="flex items-center gap-3">
<ui-checkbox
[checked]="form.enableClickHouse ?? false"
(checkedChange)="form.enableClickHouse = $event"
/>
<div>
<label uiLabel class="cursor-pointer">ClickHouse Database</label>
<p class="text-xs text-muted-foreground">A dedicated database will be created and credentials injected as CLICKHOUSE_* env vars</p>
</div>
</div>
</div>
@if (form.enableMongoDB || form.enableS3) {
@if (form.enableMongoDB || form.enableS3 || form.enableClickHouse) {
<ui-alert variant="default">
<ui-alert-description>
Platform services will be auto-deployed if not already running. Credentials are automatically injected as environment variables.
@@ -301,6 +312,7 @@ export class ServiceCreateComponent implements OnInit {
autoUpdateOnPush: false,
enableMongoDB: false,
enableS3: false,
enableClickHouse: false,
};
envVars = signal<EnvVar[]>([]);

View File

@@ -6,7 +6,7 @@ import { ApiService } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service';
import { LogStreamService } from '../../core/services/log-stream.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 {
CardComponent,
@@ -333,6 +333,77 @@ import {
</ui-card-content>
</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">
<button uiButton variant="ghost" size="sm" (click)="downloadBackup(backup)" [disabled]="backupLoading()" 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>
<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>
<!-- Logs Section -->
@@ -420,6 +491,85 @@ import {
</button>
</ui-dialog-footer>
</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 {
@@ -437,11 +587,18 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
platformResources = signal<IPlatformResource[]>([]);
stats = signal<IContainerStats | null>(null);
metrics = signal<IMetric[]>([]);
backups = signal<IBackup[]>([]);
loading = signal(false);
actionLoading = signal(false);
backupLoading = signal(false);
editMode = signal(false);
deleteDialogOpen = signal(false);
deleteBackupDialogOpen = signal(false);
restoreDialogOpen = signal(false);
selectedBackup = signal<IBackup | null>(null);
restoreMode = signal<TRestoreMode>('restore');
autoScroll = true;
restoreNewServiceName = '';
editForm: IServiceUpdate = {};
@@ -506,6 +663,9 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
this.loadPlatformResources(name);
}
// Load backups for this service
this.loadBackups(name);
// Load initial stats and metrics if service is running
// (WebSocket will keep stats updated in real-time)
if (response.data.status === 'running') {
@@ -737,4 +897,134 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
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);
}
}
async downloadBackup(backup: IBackup): Promise<void> {
this.backupLoading.set(true);
try {
await this.api.downloadBackup(backup.id!, backup.filename);
this.toast.success('Backup download started');
} catch {
this.toast.error('Failed to download backup');
} finally {
this.backupLoading.set(false);
}
}
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];
}
}

View File

@@ -1,10 +1,13 @@
import { Component, inject, signal, effect, OnInit, OnDestroy } from '@angular/core';
import { Component, inject, signal, effect, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
import { RouterLink, ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { Subscription } from 'rxjs';
import { ApiService } from '../../core/services/api.service';
import { WebSocketService } from '../../core/services/websocket.service';
import { ToastService } from '../../core/services/toast.service';
import { IService, IPlatformService, TPlatformServiceType } from '../../core/types/api.types';
import { InputComponent } from '../../ui/input/input.component';
import { LabelComponent } from '../../ui/label/label.component';
import {
CardComponent,
CardHeaderComponent,
@@ -31,14 +34,16 @@ import {
DialogFooterComponent,
} from '../../ui/dialog/dialog.component';
import { TabsComponent, TabComponent } from '../../ui/tabs/tabs.component';
import { BackupsTabComponent } from './backups-tab.component';
type TServicesTab = 'user' | 'system';
type TServicesTab = 'user' | 'system' | 'backups';
@Component({
selector: 'app-services-list',
standalone: true,
imports: [
RouterLink,
FormsModule,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
@@ -60,6 +65,9 @@ type TServicesTab = 'user' | 'system';
DialogFooterComponent,
TabsComponent,
TabComponent,
BackupsTabComponent,
InputComponent,
LabelComponent,
],
template: `
<div class="space-y-6">
@@ -70,14 +78,22 @@ type TServicesTab = 'user' | 'system';
<p class="text-muted-foreground">Manage your deployed and system services</p>
</div>
@if (activeTab() === 'user') {
<a [routerLink]="['/services/create']">
<button uiButton>
<div class="flex items-center gap-2">
<button uiButton variant="outline" (click)="openImportDialog()">
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Deploy Service
Import Backup
</button>
</a>
<a [routerLink]="['/services/create']">
<button uiButton>
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
Deploy Service
</button>
</a>
</div>
}
</div>
@@ -85,6 +101,7 @@ type TServicesTab = 'user' | 'system';
<ui-tabs class="block">
<ui-tab [active]="activeTab() === 'user'" (tabClick)="setTab('user')">User Services</ui-tab>
<ui-tab [active]="activeTab() === 'system'" (tabClick)="setTab('system')">System Services</ui-tab>
<ui-tab [active]="activeTab() === 'backups'" (tabClick)="setTab('backups')">Backups</ui-tab>
</ui-tabs>
<!-- Tab Content -->
@@ -280,6 +297,9 @@ type TServicesTab = 'user' | 'system';
</ui-card-content>
</ui-card>
}
@case ('backups') {
<app-backups-tab />
}
}
</div>
@@ -298,6 +318,122 @@ type TServicesTab = 'user' | 'system';
</button>
</ui-dialog-footer>
</ui-dialog>
<!-- Import Backup Dialog -->
<ui-dialog [open]="importDialogOpen()" (openChange)="importDialogOpen.set($event)">
<ui-dialog-header>
<ui-dialog-title>Import Backup</ui-dialog-title>
<ui-dialog-description>
Import a backup file to create a new service. The backup will be decrypted and the service restored.
</ui-dialog-description>
</ui-dialog-header>
<div class="space-y-4 py-4">
<!-- Import Mode Tabs -->
<div class="flex border-b">
<button
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
[class.border-primary]="importMode() === 'file'"
[class.text-primary]="importMode() === 'file'"
[class.border-transparent]="importMode() !== 'file'"
[class.text-muted-foreground]="importMode() !== 'file'"
(click)="importMode.set('file')"
>
Upload File
</button>
<button
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
[class.border-primary]="importMode() === 'url'"
[class.text-primary]="importMode() === 'url'"
[class.border-transparent]="importMode() !== 'url'"
[class.text-muted-foreground]="importMode() !== 'url'"
(click)="importMode.set('url')"
>
From URL
</button>
</div>
@if (importMode() === 'file') {
<!-- File Upload -->
<div class="space-y-2">
<label uiLabel>Backup File</label>
<div
class="border-2 border-dashed rounded-lg p-6 text-center cursor-pointer hover:border-primary/50 transition-colors"
[class.border-primary]="importFile()"
(click)="fileInput.click()"
(dragover)="onDragOver($event)"
(drop)="onFileDrop($event)"
>
<input
#fileInput
type="file"
accept=".tar.enc"
class="hidden"
(change)="onFileSelect($event)"
/>
@if (importFile()) {
<div class="flex items-center justify-center gap-2">
<svg class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="font-medium">{{ importFile()?.name }}</span>
<span class="text-muted-foreground text-sm">({{ formatFileSize(importFile()?.size || 0) }})</span>
</div>
} @else {
<svg class="mx-auto h-10 w-10 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p class="mt-2 text-sm text-muted-foreground">Click to select or drag and drop</p>
<p class="text-xs text-muted-foreground">Accepts .tar.enc files</p>
}
</div>
</div>
} @else {
<!-- URL Input -->
<div class="space-y-2">
<label uiLabel>Backup URL</label>
<input
uiInput
type="url"
placeholder="https://example.com/backup.tar.enc"
[value]="importUrl()"
(input)="importUrl.set($any($event.target).value)"
/>
<p class="text-xs text-muted-foreground">URL to a .tar.enc backup file</p>
</div>
}
<!-- Service Name -->
<div class="space-y-2">
<label uiLabel>Service Name (optional)</label>
<input
uiInput
type="text"
placeholder="my-service"
[value]="importServiceName()"
(input)="importServiceName.set($any($event.target).value)"
/>
<p class="text-xs text-muted-foreground">Leave empty to use the name from the backup manifest</p>
</div>
</div>
<ui-dialog-footer>
<button uiButton variant="outline" (click)="closeImportDialog()">Cancel</button>
<button
uiButton
(click)="importBackup()"
[disabled]="importLoading() || !isImportValid()"
>
@if (importLoading()) {
<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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Importing...
} @else {
Import
}
</button>
</ui-dialog-footer>
</ui-dialog>
`,
})
export class ServicesListComponent implements OnInit, OnDestroy {
@@ -323,6 +459,14 @@ export class ServicesListComponent implements OnInit, OnDestroy {
platformLoading = signal(false);
platformActionLoading = signal<TPlatformServiceType | null>(null);
// Import dialog
importDialogOpen = signal(false);
importMode = signal<'file' | 'url'>('file');
importFile = signal<File | null>(null);
importUrl = signal('');
importServiceName = signal('');
importLoading = signal(false);
constructor() {
// React to WebSocket updates
effect(() => {
@@ -338,7 +482,7 @@ export class ServicesListComponent implements OnInit, OnDestroy {
// Subscribe to route params to sync tab state with URL
this.routeSub = this.route.paramMap.subscribe((params) => {
const tab = params.get('tab') as TServicesTab;
if (tab && ['user', 'system'].includes(tab)) {
if (tab && ['user', 'system', 'backups'].includes(tab)) {
this.activeTab.set(tab);
}
});
@@ -528,4 +672,100 @@ export class ServicesListComponent implements OnInit, OnDestroy {
this.platformActionLoading.set(null);
}
}
// Import Dialog Methods
openImportDialog(): void {
this.importDialogOpen.set(true);
this.importMode.set('file');
this.importFile.set(null);
this.importUrl.set('');
this.importServiceName.set('');
}
closeImportDialog(): void {
this.importDialogOpen.set(false);
this.importFile.set(null);
this.importUrl.set('');
this.importServiceName.set('');
}
onDragOver(event: DragEvent): void {
event.preventDefault();
event.stopPropagation();
}
onFileDrop(event: DragEvent): void {
event.preventDefault();
event.stopPropagation();
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
const file = files[0];
if (file.name.endsWith('.tar.enc')) {
this.importFile.set(file);
} else {
this.toast.error('Please select a .tar.enc backup file');
}
}
}
onFileSelect(event: Event): void {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
const file = input.files[0];
if (file.name.endsWith('.tar.enc')) {
this.importFile.set(file);
} else {
this.toast.error('Please select a .tar.enc backup file');
}
}
}
isImportValid(): boolean {
if (this.importMode() === 'file') {
return this.importFile() !== null;
} else {
const url = this.importUrl().trim();
return url.length > 0 && (url.startsWith('http://') || url.startsWith('https://'));
}
}
formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
async importBackup(): Promise<void> {
if (!this.isImportValid()) return;
this.importLoading.set(true);
try {
let response;
const serviceName = this.importServiceName().trim() || undefined;
if (this.importMode() === 'file') {
const file = this.importFile();
if (!file) return;
response = await this.api.importBackupFromFile(file, serviceName);
} else {
const url = this.importUrl().trim();
response = await this.api.importBackupFromUrl(url, serviceName);
}
if (response.success && response.data) {
this.toast.success(`Service "${response.data.service.name}" imported successfully`);
this.closeImportDialog();
this.loadServices();
// Navigate to the new service
this.router.navigate(['/services/detail', response.data.service.name]);
} else {
this.toast.error(response.error || 'Failed to import backup');
}
} catch (error) {
this.toast.error('Failed to import backup');
} finally {
this.importLoading.set(false);
}
}
}

View File

@@ -30,7 +30,7 @@ export class CardComponent {
export class CardHeaderComponent {
@Input() class = '';
private baseClasses = 'p-6';
private baseClasses = 'block p-6';
get computedClasses(): string {
return `${this.baseClasses} ${this.class}`.trim();
@@ -44,6 +44,7 @@ export class CardHeaderComponent {
host: {
'[class]': 'computedClasses',
},
styles: [':host { display: block; }'],
})
export class CardTitleComponent {
@Input() class = '';