From c9beae93c878017b231c1db84e119ad823d8c2a1 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 24 Nov 2025 01:31:15 +0000 Subject: [PATCH] feat: integrate toast notifications in settings and layout components - Added ToastService for managing toast notifications. - Replaced alert in settings component with toast notifications for success and error messages. - Included ToastComponent in layout for displaying notifications. - Created loading spinner component for better user experience. - Implemented domain detail component with detailed views for certificates, requirements, and services. - Added functionality to manage and display SSL certificates and their statuses. - Introduced a registry manager class for handling Docker registry operations. --- deno.json | 4 +- ts/classes/cert-requirement-manager.ts | 2 +- ts/classes/daemon.ts | 70 ++ ts/classes/database.ts | 75 ++- ts/classes/httpserver.ts | 198 ++++++ ts/classes/onebox.ts | 33 + ts/classes/registry.ts | 237 +++++++ ts/classes/services.ts | 270 +++++++- ts/plugins.ts | 8 + ts/types.ts | 11 + ui/src/app/app.routes.ts | 11 +- ui/src/app/core/services/api.service.ts | 34 + ui/src/app/core/services/toast.service.ts | 53 ++ .../features/dashboard/dashboard.component.ts | 52 +- ui/src/app/features/dns/dns.component.ts | 34 +- .../domains/domain-detail.component.ts | 356 ++++++++++ .../app/features/domains/domains.component.ts | 196 +++++- .../services/service-create.component.ts | 182 ++++- .../services/service-detail.component.ts | 624 +++++++++++++++--- .../features/settings/settings.component.ts | 6 +- .../app/shared/components/layout.component.ts | 10 +- .../components/loading-spinner.component.ts | 48 ++ .../app/shared/components/toast.component.ts | 91 +++ 23 files changed, 2475 insertions(+), 130 deletions(-) create mode 100644 ts/classes/registry.ts create mode 100644 ui/src/app/core/services/toast.service.ts create mode 100644 ui/src/app/features/domains/domain-detail.component.ts create mode 100644 ui/src/app/shared/components/loading-spinner.component.ts create mode 100644 ui/src/app/shared/components/toast.component.ts diff --git a/deno.json b/deno.json index 2ede29c..9f1597f 100644 --- a/deno.json +++ b/deno.json @@ -19,7 +19,9 @@ "@push.rocks/smartdaemon": "npm:@push.rocks/smartdaemon@^2.1.0", "@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@2.1.0", "@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3", - "@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0" + "@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0", + "@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^1.8.0", + "@push.rocks/smarts3": "npm:@push.rocks/smarts3@^5.1.0" }, "compilerOptions": { "lib": ["deno.window", "deno.ns"], diff --git a/ts/classes/cert-requirement-manager.ts b/ts/classes/cert-requirement-manager.ts index 082b7e0..43e4cdc 100644 --- a/ts/classes/cert-requirement-manager.ts +++ b/ts/classes/cert-requirement-manager.ts @@ -8,7 +8,7 @@ import * as plugins from '../plugins.ts'; import { logger } from '../logging.ts'; import { OneboxDatabase } from './database.ts'; -import { OneboxSslManager } from './sslmanager.ts'; +import { OneboxSslManager } from './ssl.ts'; import type { ICertRequirement, ICertificate, IDomain } from '../types.ts'; export class CertRequirementManager { diff --git a/ts/classes/daemon.ts b/ts/classes/daemon.ts index d34f385..f22221b 100644 --- a/ts/classes/daemon.ts +++ b/ts/classes/daemon.ts @@ -22,6 +22,8 @@ export class OneboxDaemon { private monitoringInterval: number | null = null; private metricsInterval = 60000; // 1 minute private pidFilePath: string = PID_FILE_PATH; + private lastDomainSync = 0; // Timestamp of last Cloudflare domain sync + private domainSyncInterval = 6 * 60 * 60 * 1000; // 6 hours constructor(oneboxRef: Onebox) { this.oneboxRef = oneboxRef; @@ -211,6 +213,18 @@ export class OneboxDaemon { // Check SSL certificate expiration await this.checkSSLExpiration(); + // Process pending certificate requirements + await this.processCertRequirements(); + + // Check for certificate renewal (every tick) + await this.checkCertificateRenewal(); + + // Clean up old certificates (every tick, but cleanup has built-in 90-day threshold) + await this.cleanupOldCertificates(); + + // Sync Cloudflare domains (less frequently - every 6 hours) + await this.syncCloudflareDomainsIfNeeded(); + // Check service health (TODO: implement health checks) logger.debug('Monitoring tick complete'); @@ -267,6 +281,62 @@ export class OneboxDaemon { } } + /** + * Process pending certificate requirements + */ + private async processCertRequirements(): Promise { + try { + await this.oneboxRef.certRequirementManager.processPendingRequirements(); + } catch (error) { + logger.error(`Failed to process cert requirements: ${error.message}`); + } + } + + /** + * Check certificates for renewal (30-day threshold) + */ + private async checkCertificateRenewal(): Promise { + try { + await this.oneboxRef.certRequirementManager.checkCertificateRenewal(); + } catch (error) { + logger.error(`Failed to check certificate renewal: ${error.message}`); + } + } + + /** + * Clean up old invalid certificates (90+ days old) + */ + private async cleanupOldCertificates(): Promise { + try { + await this.oneboxRef.certRequirementManager.cleanupOldCertificates(); + } catch (error) { + logger.error(`Failed to cleanup old certificates: ${error.message}`); + } + } + + /** + * Sync Cloudflare domains if needed (every 6 hours) + */ + private async syncCloudflareDomainsIfNeeded(): Promise { + try { + const now = Date.now(); + + // Check if it's time to sync (every 6 hours) + if (now - this.lastDomainSync < this.domainSyncInterval) { + return; + } + + if (!this.oneboxRef.cloudflareDomainSync.isConfigured()) { + return; + } + + await this.oneboxRef.cloudflareDomainSync.syncZones(); + this.lastDomainSync = now; + } catch (error) { + logger.error(`Failed to sync Cloudflare domains: ${error.message}`); + } + } + /** * Keep process alive */ diff --git a/ts/classes/database.ts b/ts/classes/database.ts index 757cb4d..c16968b 100644 --- a/ts/classes/database.ts +++ b/ts/classes/database.ts @@ -505,6 +505,35 @@ export class OneboxDatabase { this.setMigrationVersion(3); logger.success('Migration 3 completed: Domain management tables created'); } + + // Migration 4: Add Onebox Registry support columns to services table + const version4 = this.getMigrationVersion(); + if (version4 < 4) { + logger.info('Running migration 4: Adding Onebox Registry columns to services table...'); + + // Add new columns for registry support + this.query(` + ALTER TABLE services ADD COLUMN use_onebox_registry INTEGER DEFAULT 0 + `); + this.query(` + ALTER TABLE services ADD COLUMN registry_repository TEXT + `); + this.query(` + ALTER TABLE services ADD COLUMN registry_token TEXT + `); + this.query(` + ALTER TABLE services ADD COLUMN registry_image_tag TEXT DEFAULT 'latest' + `); + this.query(` + ALTER TABLE services ADD COLUMN auto_update_on_push INTEGER DEFAULT 0 + `); + this.query(` + ALTER TABLE services ADD COLUMN image_digest TEXT + `); + + this.setMigrationVersion(4); + logger.success('Migration 4 completed: Onebox Registry columns added to services table'); + } } catch (error) { logger.error(`Migration failed: ${error.message}`); logger.error(`Stack: ${error.stack}`); @@ -589,8 +618,12 @@ export class OneboxDatabase { const now = Date.now(); this.query( - `INSERT INTO services (name, image, registry, env_vars, port, domain, container_id, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO services ( + name, image, registry, env_vars, port, domain, container_id, status, + created_at, updated_at, + use_onebox_registry, registry_repository, registry_token, registry_image_tag, + auto_update_on_push, image_digest + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ service.name, service.image, @@ -602,6 +635,12 @@ export class OneboxDatabase { service.status, now, now, + service.useOneboxRegistry ? 1 : 0, + service.registryRepository || null, + service.registryToken || null, + service.registryImageTag || 'latest', + service.autoUpdateOnPush ? 1 : 0, + service.imageDigest || null, ] ); @@ -663,6 +702,31 @@ export class OneboxDatabase { fields.push('status = ?'); values.push(updates.status); } + // Onebox Registry fields + if (updates.useOneboxRegistry !== undefined) { + fields.push('use_onebox_registry = ?'); + values.push(updates.useOneboxRegistry ? 1 : 0); + } + if (updates.registryRepository !== undefined) { + fields.push('registry_repository = ?'); + values.push(updates.registryRepository); + } + if (updates.registryToken !== undefined) { + fields.push('registry_token = ?'); + values.push(updates.registryToken); + } + if (updates.registryImageTag !== undefined) { + fields.push('registry_image_tag = ?'); + values.push(updates.registryImageTag); + } + if (updates.autoUpdateOnPush !== undefined) { + fields.push('auto_update_on_push = ?'); + values.push(updates.autoUpdateOnPush ? 1 : 0); + } + if (updates.imageDigest !== undefined) { + fields.push('image_digest = ?'); + values.push(updates.imageDigest); + } fields.push('updated_at = ?'); values.push(Date.now()); @@ -701,6 +765,13 @@ export class OneboxDatabase { status: String(row.status || row[8]) as IService['status'], createdAt: Number(row.created_at || row[9]), updatedAt: Number(row.updated_at || row[10]), + // Onebox Registry fields + useOneboxRegistry: row.use_onebox_registry ? Boolean(row.use_onebox_registry) : undefined, + registryRepository: row.registry_repository ? String(row.registry_repository) : undefined, + registryToken: row.registry_token ? String(row.registry_token) : undefined, + registryImageTag: row.registry_image_tag ? String(row.registry_image_tag) : undefined, + autoUpdateOnPush: row.auto_update_on_push ? Boolean(row.auto_update_on_push) : undefined, + imageDigest: row.image_digest ? String(row.image_digest) : undefined, }; } diff --git a/ts/classes/httpserver.ts b/ts/classes/httpserver.ts index d576b76..2c84ee4 100644 --- a/ts/classes/httpserver.ts +++ b/ts/classes/httpserver.ts @@ -76,6 +76,11 @@ export class OneboxHttpServer { return this.handleWebSocketUpgrade(req); } + // Docker Registry v2 API (no auth required - registry handles it) + if (path.startsWith('/v2/')) { + return await this.oneboxRef.registry.handleRequest(req); + } + // API routes if (path.startsWith('/api/')) { return await this.handleApiRequest(req, path); @@ -199,6 +204,9 @@ export class OneboxHttpServer { } else if (path.match(/^\/api\/services\/[^/]+$/) && method === 'GET') { const name = path.split('/').pop()!; return await this.handleGetServiceRequest(name); + } else if (path.match(/^\/api\/services\/[^/]+$/) && method === 'PUT') { + const name = path.split('/').pop()!; + return await this.handleUpdateServiceRequest(name, req); } else if (path.match(/^\/api\/services\/[^/]+$/) && method === 'DELETE') { const name = path.split('/').pop()!; return await this.handleDeleteServiceRequest(name); @@ -231,6 +239,21 @@ export class OneboxHttpServer { } else if (path.match(/^\/api\/domains\/[^/]+$/) && method === 'GET') { const domainName = path.split('/').pop()!; return await this.handleGetDomainDetailRequest(domainName); + } else if (path === '/api/dns' && method === 'GET') { + return await this.handleGetDnsRecordsRequest(); + } else if (path === '/api/dns' && method === 'POST') { + return await this.handleCreateDnsRecordRequest(req); + } else if (path.match(/^\/api\/dns\/[^/]+$/) && method === 'DELETE') { + const domain = path.split('/').pop()!; + return await this.handleDeleteDnsRecordRequest(domain); + } else if (path === '/api/dns/sync' && method === 'POST') { + return await this.handleSyncDnsRecordsRequest(); + } else if (path.match(/^\/api\/registry\/tags\/[^/]+$/)) { + const serviceName = path.split('/').pop()!; + return await this.handleGetRegistryTagsRequest(serviceName); + } else if (path.match(/^\/api\/registry\/token\/[^/]+$/)) { + const serviceName = path.split('/').pop()!; + return await this.handleGetRegistryTokenRequest(serviceName); } else { return this.jsonResponse({ success: false, error: 'Not found' }, 404); } @@ -338,6 +361,36 @@ export class OneboxHttpServer { } } + private async handleUpdateServiceRequest(name: string, req: Request): Promise { + try { + const body = await req.json(); + const updates: { + image?: string; + registry?: string; + port?: number; + domain?: string; + envVars?: Record; + } = {}; + + // Extract valid update fields + if (body.image !== undefined) updates.image = body.image; + if (body.registry !== undefined) updates.registry = body.registry; + if (body.port !== undefined) updates.port = body.port; + if (body.domain !== undefined) updates.domain = body.domain; + if (body.envVars !== undefined) updates.envVars = body.envVars; + + const service = await this.oneboxRef.services.updateService(name, updates); + + // Broadcast service updated + this.broadcastServiceUpdate(name, 'updated', service); + + return this.jsonResponse({ success: true, data: service }); + } catch (error) { + logger.error(`Failed to update service ${name}: ${error.message}`); + return this.jsonResponse({ success: false, error: error.message || 'Failed to update service' }, 500); + } + } + private async handleDeleteServiceRequest(name: string): Promise { try { await this.oneboxRef.services.removeService(name); @@ -659,6 +712,87 @@ export class OneboxHttpServer { } } + private async handleGetDnsRecordsRequest(): Promise { + try { + const records = this.oneboxRef.dns.listDNSRecords(); + return this.jsonResponse({ success: true, data: records }); + } catch (error) { + logger.error(`Failed to get DNS records: ${error.message}`); + return this.jsonResponse({ + success: false, + error: error.message || 'Failed to get DNS records', + }, 500); + } + } + + private async handleCreateDnsRecordRequest(req: Request): Promise { + try { + const body = await req.json(); + const { domain, ip } = body; + + if (!domain) { + return this.jsonResponse( + { success: false, error: 'Domain is required' }, + 400 + ); + } + + await this.oneboxRef.dns.addDNSRecord(domain, ip); + + return this.jsonResponse({ + success: true, + message: `DNS record created for ${domain}`, + }); + } catch (error) { + logger.error(`Failed to create DNS record: ${error.message}`); + return this.jsonResponse({ + success: false, + error: error.message || 'Failed to create DNS record', + }, 500); + } + } + + private async handleDeleteDnsRecordRequest(domain: string): Promise { + try { + await this.oneboxRef.dns.removeDNSRecord(domain); + + return this.jsonResponse({ + success: true, + message: `DNS record deleted for ${domain}`, + }); + } catch (error) { + logger.error(`Failed to delete DNS record for ${domain}: ${error.message}`); + return this.jsonResponse({ + success: false, + error: error.message || 'Failed to delete DNS record', + }, 500); + } + } + + private async handleSyncDnsRecordsRequest(): Promise { + try { + if (!this.oneboxRef.dns.isConfigured()) { + return this.jsonResponse({ + success: false, + error: 'DNS manager not configured', + }, 400); + } + + await this.oneboxRef.dns.syncFromCloudflare(); + + return this.jsonResponse({ + success: true, + message: 'DNS records synced from Cloudflare', + }); + } catch (error) { + logger.error(`Failed to sync DNS records: ${error.message}`); + return this.jsonResponse({ + success: false, + error: error.message || 'Failed to sync DNS records', + }, 500); + } + } + /** * Handle WebSocket upgrade */ @@ -755,6 +889,70 @@ export class OneboxHttpServer { }); } + // ============ Registry Endpoints ============ + + private async handleGetRegistryTagsRequest(serviceName: string): Promise { + try { + const tags = await this.oneboxRef.registry.getImageTags(serviceName); + return this.jsonResponse({ success: true, data: tags }); + } catch (error) { + logger.error(`Failed to get registry tags for ${serviceName}: ${error.message}`); + return this.jsonResponse({ + success: false, + error: error.message || 'Failed to get registry tags', + }, 500); + } + } + + private async handleGetRegistryTokenRequest(serviceName: string): Promise { + try { + // Get the service to verify it exists + const service = this.oneboxRef.database.getServiceByName(serviceName); + if (!service) { + return this.jsonResponse({ + success: false, + error: 'Service not found', + }, 404); + } + + // If service already has a token, return it + if (service.registryToken) { + return this.jsonResponse({ + success: true, + data: { + token: service.registryToken, + repository: serviceName, + baseUrl: this.oneboxRef.registry.getBaseUrl(), + }, + }); + } + + // Generate new token + const token = await this.oneboxRef.registry.createServiceToken(serviceName); + + // Save token to database + this.oneboxRef.database.updateService(service.id!, { + registryToken: token, + registryRepository: serviceName, + }); + + return this.jsonResponse({ + success: true, + data: { + token: token, + repository: serviceName, + baseUrl: this.oneboxRef.registry.getBaseUrl(), + }, + }); + } catch (error) { + logger.error(`Failed to get registry token for ${serviceName}: ${error.message}`); + return this.jsonResponse({ + success: false, + error: error.message || 'Failed to get registry token', + }, 500); + } + } + /** * Helper to create JSON response */ diff --git a/ts/classes/onebox.ts b/ts/classes/onebox.ts index d896870..c2e5247 100644 --- a/ts/classes/onebox.ts +++ b/ts/classes/onebox.ts @@ -14,6 +14,9 @@ import { OneboxDnsManager } from './dns.ts'; import { OneboxSslManager } from './ssl.ts'; import { OneboxDaemon } from './daemon.ts'; import { OneboxHttpServer } from './httpserver.ts'; +import { CloudflareDomainSync } from './cloudflare-sync.ts'; +import { CertRequirementManager } from './cert-requirement-manager.ts'; +import { RegistryManager } from './registry.ts'; export class Onebox { public database: OneboxDatabase; @@ -25,6 +28,9 @@ export class Onebox { public ssl: OneboxSslManager; public daemon: OneboxDaemon; public httpServer: OneboxHttpServer; + public cloudflareDomainSync: CloudflareDomainSync; + public certRequirementManager: CertRequirementManager; + public registry: RegistryManager; private initialized = false; @@ -41,6 +47,15 @@ export class Onebox { this.ssl = new OneboxSslManager(this); this.daemon = new OneboxDaemon(this); this.httpServer = new OneboxHttpServer(this); + this.registry = new RegistryManager({ + dataDir: './.nogit/registry-data', + port: 4000, + baseUrl: 'localhost:5000', + }); + + // Initialize domain management + this.cloudflareDomainSync = new CloudflareDomainSync(this.database); + this.certRequirementManager = new CertRequirementManager(this.database, this.ssl); } /** @@ -76,9 +91,27 @@ export class Onebox { logger.warn('SSL initialization failed - SSL features will be limited'); } + // Initialize Cloudflare domain sync (non-critical) + try { + await this.cloudflareDomainSync.init(); + } catch (error) { + logger.warn('Cloudflare domain sync initialization failed - domain sync will be limited'); + } + + // Initialize Onebox Registry (non-critical) + try { + await this.registry.init(); + } catch (error) { + logger.warn('Onebox Registry initialization failed - local registry will be disabled'); + logger.warn(`Error: ${error.message}`); + } + // Login to all registries await this.registries.loginToAllRegistries(); + // Start auto-update monitoring for registry services + this.services.startAutoUpdateMonitoring(); + this.initialized = true; logger.success('Onebox initialized successfully'); } catch (error) { diff --git a/ts/classes/registry.ts b/ts/classes/registry.ts new file mode 100644 index 0000000..a8cd7f0 --- /dev/null +++ b/ts/classes/registry.ts @@ -0,0 +1,237 @@ +/** + * Onebox Registry Manager + * + * Manages the local Docker registry using: + * - @push.rocks/smarts3 (S3-compatible server with filesystem storage) + * - @push.rocks/smartregistry (OCI-compliant Docker registry) + */ + +import * as plugins from '../plugins.ts'; +import { logger } from '../logging.ts'; + +export class RegistryManager { + private s3Server: any = null; + private registry: any = null; + private jwtSecret: string; + private baseUrl: string; + private isInitialized = false; + + constructor(private options: { + dataDir?: string; + port?: number; + baseUrl?: string; + } = {}) { + this.jwtSecret = this.getJwtSecret(); + this.baseUrl = options.baseUrl || 'localhost:5000'; + } + + /** + * Initialize the registry (start smarts3 and smartregistry) + */ + async init(): Promise { + if (this.isInitialized) { + logger.warn('Registry already initialized'); + return; + } + + try { + const dataDir = this.options.dataDir || './.nogit/registry-data'; + const port = this.options.port || 4000; + + logger.info(`Starting smarts3 server on port ${port}...`); + + // 1. Start smarts3 server (S3-compatible storage with filesystem backend) + this.s3Server = await plugins.smarts3.Smarts3.createAndStart({ + server: { + port: port, + host: '0.0.0.0', + }, + storage: { + bucketsDir: dataDir, + cleanSlate: false, // Preserve data across restarts + }, + }); + + logger.success(`smarts3 server started on port ${port}`); + + // 2. Configure smartregistry to use smarts3 + logger.info('Initializing smartregistry...'); + + this.registry = new plugins.smartregistry.SmartRegistry({ + storage: { + endpoint: 'localhost', + port: port, + accessKey: 'onebox', // smarts3 doesn't validate credentials + accessSecret: 'onebox', + useSsl: false, + region: 'us-east-1', + bucketName: 'onebox-registry', + }, + auth: { + jwtSecret: this.jwtSecret, + ociTokens: { + enabled: true, + issuer: 'onebox-registry', + service: 'onebox-registry', + }, + }, + oci: { + enabled: true, + basePath: '/v2', + }, + }); + + await this.registry.init(); + + this.isInitialized = true; + logger.success('Onebox Registry initialized successfully'); + } catch (error) { + logger.error(`Failed to initialize registry: ${error.message}`); + throw error; + } + } + + /** + * Handle incoming HTTP requests to the registry + */ + async handleRequest(req: Request): Promise { + if (!this.isInitialized) { + return new Response('Registry not initialized', { status: 503 }); + } + + try { + return await this.registry.handleRequest(req); + } catch (error) { + logger.error(`Registry request error: ${error.message}`); + return new Response('Internal registry error', { status: 500 }); + } + } + + /** + * Create a push/pull token for a service + */ + async createServiceToken(serviceName: string): Promise { + if (!this.isInitialized) { + throw new Error('Registry not initialized'); + } + + const repository = serviceName; + const scopes = [ + `oci:repository:${repository}:push`, + `oci:repository:${repository}:pull`, + ]; + + // Create OCI JWT token (expires in 1 year = 365 * 24 * 60 * 60 seconds) + const token = await this.registry.authManager.createOciToken( + 'onebox', + scopes, + 31536000 // 365 days in seconds + ); + + return token; + } + + /** + * Get all tags for a repository + */ + async getImageTags(repository: string): Promise { + if (!this.isInitialized) { + throw new Error('Registry not initialized'); + } + + try { + const tags = await this.registry.getTags(repository); + return tags || []; + } catch (error) { + logger.warn(`Failed to get tags for ${repository}: ${error.message}`); + return []; + } + } + + /** + * Get the manifest digest for a specific image tag + */ + async getImageDigest(repository: string, tag: string): Promise { + if (!this.isInitialized) { + throw new Error('Registry not initialized'); + } + + try { + const manifest = await this.registry.getManifest(repository, tag); + if (manifest && manifest.digest) { + return manifest.digest; + } + return null; + } catch (error) { + logger.warn(`Failed to get digest for ${repository}:${tag}: ${error.message}`); + return null; + } + } + + /** + * Delete an image by tag + */ + async deleteImage(repository: string, tag: string): Promise { + if (!this.isInitialized) { + throw new Error('Registry not initialized'); + } + + try { + await this.registry.deleteManifest(repository, tag); + logger.info(`Deleted image ${repository}:${tag}`); + } catch (error) { + logger.error(`Failed to delete image ${repository}:${tag}: ${error.message}`); + throw error; + } + } + + /** + * Get or generate the JWT secret for token signing + */ + private getJwtSecret(): string { + // In production, this should be stored securely + // For now, use a consistent secret stored in environment or generate one + const secret = Deno.env.get('REGISTRY_JWT_SECRET'); + if (secret) { + return secret; + } + + // Generate a random secret (this will be different on each restart) + // In production, you'd want to persist this + const randomSecret = crypto.randomUUID() + crypto.randomUUID(); + logger.warn('Using generated JWT secret (will be different on restart)'); + logger.warn('Set REGISTRY_JWT_SECRET environment variable for persistence'); + return randomSecret; + } + + /** + * Get the registry base URL + */ + getBaseUrl(): string { + return this.baseUrl; + } + + /** + * Get the full image name for a service + */ + getImageName(serviceName: string, tag: string = 'latest'): string { + return `${this.baseUrl}/${serviceName}:${tag}`; + } + + /** + * Stop the registry and smarts3 server + */ + async stop(): Promise { + if (this.s3Server) { + try { + await this.s3Server.stop(); + logger.info('smarts3 server stopped'); + } catch (error) { + logger.error(`Error stopping smarts3: ${error.message}`); + } + } + + this.isInitialized = false; + logger.info('Registry stopped'); + } +} diff --git a/ts/classes/services.ts b/ts/classes/services.ts index 2d7daf0..4a83943 100644 --- a/ts/classes/services.ts +++ b/ts/classes/services.ts @@ -33,10 +33,26 @@ export class OneboxServicesManager { throw new Error(`Service already exists: ${options.name}`); } + // Handle Onebox Registry setup + let registryToken: string | undefined; + let imageToPull: string; + + if (options.useOneboxRegistry) { + // Generate registry token + registryToken = await this.oneboxRef.registry.createServiceToken(options.name); + + // Use onebox registry image name + const tag = options.registryImageTag || 'latest'; + imageToPull = this.oneboxRef.registry.getImageName(options.name, tag); + } else { + // Use external image + imageToPull = options.image; + } + // Create service record in database const service = await this.database.createService({ name: options.name, - image: options.image, + image: options.useOneboxRegistry ? imageToPull : options.image, registry: options.registry, envVars: options.envVars || {}, port: options.port, @@ -44,10 +60,18 @@ export class OneboxServicesManager { status: 'stopped', createdAt: Date.now(), updatedAt: Date.now(), + // Onebox Registry fields + useOneboxRegistry: options.useOneboxRegistry, + registryRepository: options.useOneboxRegistry ? options.name : undefined, + registryToken: registryToken, + registryImageTag: options.registryImageTag || 'latest', + autoUpdateOnPush: options.autoUpdateOnPush, }); - // Pull image - await this.docker.pullImage(options.image, options.registry); + // Pull image (skip if using onebox registry - image might not exist yet) + if (!options.useOneboxRegistry) { + await this.docker.pullImage(imageToPull, options.registry); + } // Create container const containerID = await this.docker.createContainer(service); @@ -68,6 +92,47 @@ export class OneboxServicesManager { if (options.domain) { logger.info(`Configuring domain: ${options.domain}`); + // Validate domain and create CertRequirement + try { + // Extract base domain (e.g., "api.example.com" -> "example.com") + const domainParts = options.domain.split('.'); + const baseDomain = domainParts.slice(-2).join('.'); + const subdomain = domainParts.length > 2 ? domainParts.slice(0, -2).join('.') : ''; + + // Check if base domain exists in Domain table + const domainRecord = this.database.getDomainByName(baseDomain); + + if (!domainRecord) { + logger.warn( + `Domain ${baseDomain} not found in Domain table. ` + + `Service will deploy but certificate management may not work. ` + + `Run Cloudflare domain sync or manually add the domain.` + ); + } else if (domainRecord.isObsolete) { + logger.warn( + `Domain ${baseDomain} is marked as obsolete. ` + + `Certificate management may not work properly.` + ); + } else { + // Create CertRequirement for automatic certificate management + const now = Date.now(); + this.database.createCertRequirement({ + serviceId: service.id!, + domainId: domainRecord.id!, + subdomain: subdomain, + status: 'pending', + createdAt: now, + updatedAt: now, + }); + logger.info( + `Created certificate requirement for ${options.domain} ` + + `(domain: ${baseDomain}, subdomain: ${subdomain || 'none'})` + ); + } + } catch (error) { + logger.warn(`Failed to create certificate requirement: ${error.message}`); + } + // Configure DNS (if autoDNS is enabled) if (options.autoDNS !== false) { try { @@ -85,6 +150,8 @@ export class OneboxServicesManager { } // Configure SSL (if autoSSL is enabled) + // Note: With CertRequirement system, certificates are managed automatically + // but we still support the old direct obtainCertificate for backward compatibility if (options.autoSSL !== false) { try { await this.oneboxRef.ssl.obtainCertificate(options.domain); @@ -362,6 +429,121 @@ export class OneboxServicesManager { } } + /** + * Update service configuration (image, port, domain, env vars) + * Recreates the container with new configuration and auto-restarts + */ + async updateService( + name: string, + updates: { + image?: string; + registry?: string; + port?: number; + domain?: string; + envVars?: Record; + } + ): Promise { + try { + const service = this.database.getServiceByName(name); + if (!service) { + throw new Error(`Service not found: ${name}`); + } + + logger.info(`Updating service: ${name}`); + const wasRunning = service.status === 'running'; + const oldContainerID = service.containerID; + const oldDomain = service.domain; + + // Stop the container if running + if (wasRunning && oldContainerID) { + logger.info(`Stopping service ${name} for updates...`); + try { + await this.docker.stopContainer(oldContainerID); + } catch (error) { + logger.warn(`Failed to stop container: ${error.message}`); + } + } + + // Pull new image if changed + if (updates.image && updates.image !== service.image) { + logger.info(`Pulling new image: ${updates.image}`); + await this.docker.pullImage(updates.image, updates.registry || service.registry); + } + + // Update service in database + const updateData: any = { + updatedAt: Date.now(), + }; + if (updates.image !== undefined) updateData.image = updates.image; + if (updates.registry !== undefined) updateData.registry = updates.registry; + if (updates.port !== undefined) updateData.port = updates.port; + if (updates.domain !== undefined) updateData.domain = updates.domain; + if (updates.envVars !== undefined) updateData.envVars = updates.envVars; + + this.database.updateService(service.id!, updateData); + + // Get updated service + const updatedService = this.database.getServiceByName(name)!; + + // Remove old container + if (oldContainerID) { + try { + await this.docker.removeContainer(oldContainerID, true); + logger.info(`Removed old container for ${name}`); + } catch (error) { + logger.warn(`Failed to remove old container: ${error.message}`); + } + } + + // Create new container with updated config + logger.info(`Creating new container for ${name}...`); + const containerID = await this.docker.createContainer(updatedService); + this.database.updateService(service.id!, { containerID }); + + // Update reverse proxy if domain changed + if (updates.domain !== undefined && updates.domain !== oldDomain) { + // Remove old route if it existed + if (oldDomain) { + try { + this.oneboxRef.reverseProxy.removeRoute(oldDomain); + } catch (error) { + logger.warn(`Failed to remove old reverse proxy route: ${error.message}`); + } + } + + // Add new route if domain specified + if (updates.domain) { + try { + await this.oneboxRef.reverseProxy.addRoute( + service.id!, + updates.domain, + updates.port || service.port + ); + } catch (error) { + logger.warn(`Failed to configure reverse proxy: ${error.message}`); + } + } + } + + // Restart the container if it was running + if (wasRunning) { + logger.info(`Starting updated service ${name}...`); + this.database.updateService(service.id!, { status: 'starting' }); + await this.docker.startContainer(containerID); + this.database.updateService(service.id!, { status: 'running' }); + logger.success(`Service ${name} updated and restarted`); + } else { + this.database.updateService(service.id!, { status: 'stopped' }); + logger.success(`Service ${name} updated (not started)`); + } + + return this.database.getServiceByName(name)!; + } catch (error) { + logger.error(`Failed to update service ${name}: ${error.message}`); + throw error; + } + } + /** * Sync service status from Docker */ @@ -410,4 +592,86 @@ export class OneboxServicesManager { await this.syncServiceStatus(service.name); } } + + /** + * Start auto-update monitoring for registry services + * Polls every 30 seconds for digest changes and restarts services if needed + */ + startAutoUpdateMonitoring(): void { + // Check every 30 seconds + setInterval(async () => { + try { + await this.checkForRegistryUpdates(); + } catch (error) { + logger.error(`Auto-update check failed: ${error.message}`); + } + }, 30000); + + logger.info('Auto-update monitoring started (30s interval)'); + } + + /** + * Check all services using onebox registry for updates + */ + private async checkForRegistryUpdates(): Promise { + const services = this.listServices(); + + for (const service of services) { + // Skip if not using onebox registry or auto-update is disabled + if (!service.useOneboxRegistry || !service.autoUpdateOnPush) { + continue; + } + + try { + // Get current digest from registry + const currentDigest = await this.oneboxRef.registry.getImageDigest( + service.registryRepository!, + service.registryImageTag || 'latest' + ); + + // Skip if no digest found (image might not exist yet) + if (!currentDigest) { + continue; + } + + // Check if digest has changed + if (service.imageDigest && service.imageDigest !== currentDigest) { + logger.info( + `Digest changed for ${service.name}: ${service.imageDigest} -> ${currentDigest}` + ); + + // Update digest in database + this.database.updateService(service.id!, { + imageDigest: currentDigest, + }); + + // Pull new image + const imageName = this.oneboxRef.registry.getImageName( + service.registryRepository!, + service.registryImageTag || 'latest' + ); + + logger.info(`Pulling updated image: ${imageName}`); + await this.docker.pullImage(imageName); + + // Restart service + logger.info(`Auto-restarting service: ${service.name}`); + await this.restartService(service.name); + + // Broadcast update via WebSocket + this.oneboxRef.httpServer.broadcastServiceUpdate({ + action: 'updated', + service: this.database.getServiceByName(service.name)!, + }); + } else if (!service.imageDigest) { + // First time - just store the digest + this.database.updateService(service.id!, { + imageDigest: currentDigest, + }); + } + } catch (error) { + logger.error(`Failed to check updates for ${service.name}: ${error.message}`); + } + } + } } diff --git a/ts/plugins.ts b/ts/plugins.ts index 85e083b..36a4273 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -33,6 +33,14 @@ export { cloudflare }; import * as smartacme from '@push.rocks/smartacme'; export { smartacme }; +// Docker Registry (OCI Distribution Specification) +import * as smartregistry from '@push.rocks/smartregistry'; +export { smartregistry }; + +// S3-compatible storage server +import * as smarts3 from '@push.rocks/smarts3'; +export { smarts3 }; + // Crypto utilities (for password hashing, encryption) import * as bcrypt from 'https://deno.land/x/bcrypt@v0.4.1/mod.ts'; export { bcrypt }; diff --git a/ts/types.ts b/ts/types.ts index 9001abe..c3141bc 100644 --- a/ts/types.ts +++ b/ts/types.ts @@ -15,6 +15,13 @@ export interface IService { status: 'stopped' | 'starting' | 'running' | 'stopping' | 'failed'; createdAt: number; updatedAt: number; + // Onebox Registry fields + useOneboxRegistry?: boolean; + registryRepository?: string; + registryToken?: string; + registryImageTag?: string; + autoUpdateOnPush?: boolean; + imageDigest?: string; } // Registry types @@ -182,6 +189,10 @@ export interface IServiceDeployOptions { domain?: string; autoSSL?: boolean; autoDNS?: boolean; + // Onebox Registry options + useOneboxRegistry?: boolean; + registryImageTag?: string; + autoUpdateOnPush?: boolean; } // HTTP API request/response types diff --git a/ui/src/app/app.routes.ts b/ui/src/app/app.routes.ts index 2e3f62f..4942650 100644 --- a/ui/src/app/app.routes.ts +++ b/ui/src/app/app.routes.ts @@ -57,9 +57,16 @@ export const routes: Routes = [ import('./features/dns/dns.component').then((m) => m.DnsComponent), }, { - path: 'ssl', + path: 'domains', loadComponent: () => - import('./features/ssl/ssl.component').then((m) => m.SslComponent), + import('./features/domains/domains.component').then((m) => m.DomainsComponent), + }, + { + path: 'domains/:domain', + loadComponent: () => + import('./features/domains/domain-detail.component').then( + (m) => m.DomainDetailComponent + ), }, { path: 'settings', diff --git a/ui/src/app/core/services/api.service.ts b/ui/src/app/core/services/api.service.ts index aeb32db..f74cb40 100644 --- a/ui/src/app/core/services/api.service.ts +++ b/ui/src/app/core/services/api.service.ts @@ -21,6 +21,13 @@ export interface Service { status: 'stopped' | 'starting' | 'running' | 'stopping' | 'failed'; createdAt: number; updatedAt: number; + // Onebox Registry fields + useOneboxRegistry?: boolean; + registryRepository?: string; + registryToken?: string; + registryImageTag?: string; + autoUpdateOnPush?: boolean; + imageDigest?: string; } export interface Registry { @@ -106,6 +113,16 @@ export class ApiService { return this.http.get>(`${this.baseUrl}/services/${name}/logs`); } + updateService(name: string, updates: { + image?: string; + registry?: string; + port?: number; + domain?: string; + envVars?: Record; + }): Observable> { + return this.http.put>(`${this.baseUrl}/services/${name}`, updates); + } + // Registries getRegistries(): Observable> { return this.http.get>(`${this.baseUrl}/registries`); @@ -132,6 +149,10 @@ export class ApiService { return this.http.delete(`${this.baseUrl}/dns/${domain}`); } + syncDnsRecords(): Observable { + return this.http.post(`${this.baseUrl}/dns/sync`, {}); + } + // SSL getSslCertificates(): Observable> { return this.http.get>(`${this.baseUrl}/ssl`); @@ -141,6 +162,19 @@ export class ApiService { return this.http.post(`${this.baseUrl}/ssl/${domain}/renew`, {}); } + // Domains + getDomains(): Observable> { + return this.http.get>(`${this.baseUrl}/domains`); + } + + getDomainDetail(domain: string): Observable> { + return this.http.get>(`${this.baseUrl}/domains/${domain}`); + } + + syncCloudflareDomains(): Observable { + return this.http.post(`${this.baseUrl}/domains/sync`, {}); + } + // Settings getSettings(): Observable>> { return this.http.get>>(`${this.baseUrl}/settings`); diff --git a/ui/src/app/core/services/toast.service.ts b/ui/src/app/core/services/toast.service.ts new file mode 100644 index 0000000..62bf0e5 --- /dev/null +++ b/ui/src/app/core/services/toast.service.ts @@ -0,0 +1,53 @@ +import { Injectable, signal } from '@angular/core'; + +export type ToastType = 'success' | 'error' | 'info' | 'warning'; + +export interface Toast { + id: string; + type: ToastType; + message: string; + duration?: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class ToastService { + toasts = signal([]); + private nextId = 0; + + show(type: ToastType, message: string, duration: number = 5000) { + const id = `toast-${this.nextId++}`; + const toast: Toast = { id, type, message, duration }; + + this.toasts.update(toasts => [...toasts, toast]); + + if (duration > 0) { + setTimeout(() => this.remove(id), duration); + } + } + + success(message: string, duration?: number) { + this.show('success', message, duration); + } + + error(message: string, duration?: number) { + this.show('error', message, duration); + } + + info(message: string, duration?: number) { + this.show('info', message, duration); + } + + warning(message: string, duration?: number) { + this.show('warning', message, duration); + } + + remove(id: string) { + this.toasts.update(toasts => toasts.filter(t => t.id !== id)); + } + + clear() { + this.toasts.set([]); + } +} diff --git a/ui/src/app/features/dashboard/dashboard.component.ts b/ui/src/app/features/dashboard/dashboard.component.ts index 7cc26e2..656ef51 100644 --- a/ui/src/app/features/dashboard/dashboard.component.ts +++ b/ui/src/app/features/dashboard/dashboard.component.ts @@ -1,7 +1,9 @@ -import { Component, OnInit, inject, signal } from '@angular/core'; +import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; import { ApiService, SystemStatus } from '../../core/services/api.service'; +import { WebSocketService } from '../../core/services/websocket.service'; +import { Subscription } from 'rxjs'; @Component({ selector: 'app-dashboard', @@ -9,7 +11,22 @@ import { ApiService, SystemStatus } from '../../core/services/api.service'; imports: [CommonModule, RouterLink], template: `
-

Dashboard

+
+

Dashboard

+
+ @if (lastUpdated()) { + + Last updated: {{ lastUpdated()!.toLocaleTimeString() }} + + } + +
+
@if (loading()) {
@@ -169,14 +186,38 @@ import { ApiService, SystemStatus } from '../../core/services/api.service';
`, }) -export class DashboardComponent implements OnInit { +export class DashboardComponent implements OnInit, OnDestroy { private apiService = inject(ApiService); + private wsService = inject(WebSocketService); status = signal(null); loading = signal(true); + lastUpdated = signal(null); + private wsSubscription?: Subscription; + private refreshInterval?: number; ngOnInit(): void { this.loadStatus(); + + // Subscribe to WebSocket updates + this.wsSubscription = this.wsService.getMessages().subscribe((message: any) => { + // Reload status on any service or system update + if (message.type === 'service_update' || message.type === 'service_status' || message.type === 'system_status') { + this.loadStatus(); + } + }); + + // Auto-refresh every 30 seconds + this.refreshInterval = window.setInterval(() => { + this.loadStatus(); + }, 30000); + } + + ngOnDestroy(): void { + this.wsSubscription?.unsubscribe(); + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + } } loadStatus(): void { @@ -185,6 +226,7 @@ export class DashboardComponent implements OnInit { next: (response) => { if (response.success && response.data) { this.status.set(response.data); + this.lastUpdated.set(new Date()); } this.loading.set(false); }, @@ -193,4 +235,8 @@ export class DashboardComponent implements OnInit { }, }); } + + refresh(): void { + this.loadStatus(); + } } diff --git a/ui/src/app/features/dns/dns.component.ts b/ui/src/app/features/dns/dns.component.ts index 1a9912a..f201238 100644 --- a/ui/src/app/features/dns/dns.component.ts +++ b/ui/src/app/features/dns/dns.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ApiService } from '../../core/services/api.service'; +import { ToastService } from '../../core/services/toast.service'; @Component({ selector: 'app-dns', @@ -9,7 +10,16 @@ import { ApiService } from '../../core/services/api.service'; imports: [CommonModule, FormsModule], template: `
-

DNS Records

+
+

DNS Records

+ +
@if (records().length > 0) {
@@ -40,6 +50,7 @@ import { ApiService } from '../../core/services/api.service';

No DNS records configured

DNS records are created automatically when deploying services with domains

+

Or click "Sync Cloudflare" to import existing DNS records from Cloudflare

}
@@ -47,7 +58,9 @@ import { ApiService } from '../../core/services/api.service'; }) export class DnsComponent implements OnInit { private apiService = inject(ApiService); + private toastService = inject(ToastService); records = signal([]); + syncing = signal(false); ngOnInit(): void { this.loadRecords(); @@ -63,6 +76,25 @@ export class DnsComponent implements OnInit { }); } + syncRecords(): void { + this.syncing.set(true); + this.apiService.syncDnsRecords().subscribe({ + next: (response) => { + if (response.success) { + this.toastService.success('Cloudflare DNS records synced successfully'); + this.loadRecords(); + } else { + this.toastService.error(response.error || 'Failed to sync DNS records'); + } + this.syncing.set(false); + }, + error: () => { + this.toastService.error('Failed to sync DNS records'); + this.syncing.set(false); + }, + }); + } + deleteRecord(record: any): void { if (confirm(`Delete DNS record for ${record.domain}?`)) { this.apiService.deleteDnsRecord(record.domain).subscribe({ diff --git a/ui/src/app/features/domains/domain-detail.component.ts b/ui/src/app/features/domains/domain-detail.component.ts new file mode 100644 index 0000000..db698a6 --- /dev/null +++ b/ui/src/app/features/domains/domain-detail.component.ts @@ -0,0 +1,356 @@ +import { Component, OnInit, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { ApiService } from '../../core/services/api.service'; + +interface DomainDetail { + domain: { + id: number; + domain: string; + dnsProvider: 'cloudflare' | 'manual' | null; + cloudflareZoneId?: string; + isObsolete: boolean; + defaultWildcard: boolean; + createdAt: number; + updatedAt: number; + }; + certificates: Array<{ + id: number; + certDomain: string; + isWildcard: boolean; + expiryDate: number; + issuer: string; + isValid: boolean; + createdAt: number; + }>; + requirements: Array<{ + id: number; + serviceId: number; + subdomain: string; + status: 'pending' | 'active' | 'renewing'; + certificateId?: number; + }>; + services: Array<{ + id: number; + name: string; + domain: string; + status: string; + }>; +} + +@Component({ + selector: 'app-domain-detail', + standalone: true, + imports: [CommonModule, RouterLink], + template: ` +
+ +
+ + + @if (loading()) { +
+

Loading domain details...

+
+ } @else if (domainDetail()) { +
+
+
+

+ {{ domainDetail()!.domain.domain }} +

+
+ @if (domainDetail()!.domain.dnsProvider === 'cloudflare') { + + Cloudflare + + } @else if (domainDetail()!.domain.dnsProvider === 'manual') { + + Manual DNS + + } + @if (domainDetail()!.domain.defaultWildcard) { + + Wildcard Enabled + + } + @if (domainDetail()!.domain.isObsolete) { + + Obsolete + + } +
+
+
+ + +
+
+
Certificates
+
+ {{ domainDetail()!.certificates.length }} +
+
+
+
Requirements
+
+ {{ domainDetail()!.requirements.length }} +
+
+
+
Services
+
+ {{ domainDetail()!.services.length }} +
+
+
+ + +
+

SSL Certificates

+ @if (domainDetail()!.certificates.length > 0) { +
+ + + + + + + + + + + + @for (cert of domainDetail()!.certificates; track cert.id) { + + + + + + + + } + +
DomainTypeStatusExpiresIssuer
+ {{ cert.certDomain }} + + @if (cert.isWildcard) { + + Wildcard + + } @else { + + Standard + + } + + @if (getCertStatus(cert) === 'valid') { + + Valid + + } @else if (getCertStatus(cert) === 'expiring') { + + Expiring Soon + + } @else { + + Expired/Invalid + + } + + {{ formatDate(cert.expiryDate) }} + ({{ getDaysRemaining(cert.expiryDate) }} days) + + {{ cert.issuer }} +
+
+ } @else { +
+

No certificates for this domain

+
+ } +
+ + +
+

Certificate Requirements

+ @if (domainDetail()!.requirements.length > 0) { +
+ + + + + + + + + + + @for (req of domainDetail()!.requirements; track req.id) { + + + + + + + } + +
ServiceSubdomainStatusCertificate ID
+ {{ getServiceName(req.serviceId) }} + + {{ req.subdomain || '(root)' }} + + @if (req.status === 'active') { + + Active + + } @else if (req.status === 'pending') { + + Pending + + } @else if (req.status === 'renewing') { + + Renewing + + } + + {{ req.certificateId || '—' }} +
+
+ } @else { +
+

No certificate requirements

+
+ } +
+ + +
+

Services Using This Domain

+ @if (domainDetail()!.services.length > 0) { +
+ + + + + + + + + + + @for (service of domainDetail()!.services; track service.id) { + + + + + + + } + +
NameDomainStatusActions
+ {{ service.name }} + + {{ service.domain }} + + @if (service.status === 'running') { + + Running + + } @else if (service.status === 'stopped') { + + Stopped + + } @else { + + {{ service.status }} + + } + + + View Service + +
+
+ } @else { +
+

No services using this domain

+
+ } +
+
+ } @else { +
+

Domain not found

+
+ } +
+
+ `, +}) +export class DomainDetailComponent implements OnInit { + private route = inject(ActivatedRoute); + private apiService = inject(ApiService); + + domainDetail = signal(null); + loading = signal(true); + + ngOnInit(): void { + const domain = this.route.snapshot.paramMap.get('domain'); + if (domain) { + this.loadDomainDetail(domain); + } + } + + loadDomainDetail(domain: string): void { + this.loading.set(true); + this.apiService.getDomainDetail(domain).subscribe({ + next: (response) => { + if (response.success && response.data) { + this.domainDetail.set(response.data); + } + this.loading.set(false); + }, + error: () => { + this.loading.set(false); + }, + }); + } + + formatDate(timestamp: number): string { + return new Date(timestamp).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } + + getDaysRemaining(expiryDate: number): number { + const now = Date.now(); + const diff = expiryDate - now; + return Math.floor(diff / (24 * 60 * 60 * 1000)); + } + + getCertStatus(cert: any): 'valid' | 'expiring' | 'invalid' { + if (!cert.isValid) return 'invalid'; + const daysRemaining = this.getDaysRemaining(cert.expiryDate); + if (daysRemaining < 0) return 'invalid'; + if (daysRemaining <= 30) return 'expiring'; + return 'valid'; + } + + getServiceName(serviceId: number): string { + const service = this.domainDetail()?.services.find((s) => s.id === serviceId); + return service?.name || `Service #${serviceId}`; + } +} diff --git a/ui/src/app/features/domains/domains.component.ts b/ui/src/app/features/domains/domains.component.ts index 2dbca86..a47f55b 100644 --- a/ui/src/app/features/domains/domains.component.ts +++ b/ui/src/app/features/domains/domains.component.ts @@ -1,86 +1,216 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { RouterLink } from '@angular/router'; import { ApiService } from '../../core/services/api.service'; +import { ToastService } from '../../core/services/toast.service'; + +interface DomainView { + domain: { + id: number; + domain: string; + dnsProvider: 'cloudflare' | 'manual' | null; + isObsolete: boolean; + defaultWildcard: boolean; + }; + serviceCount: number; + certificateStatus: 'valid' | 'expiring-soon' | 'expired' | 'pending' | 'none'; + daysRemaining: number | null; + certificates: any[]; + requirements: any[]; +} @Component({ - selector: 'app-ssl', + selector: 'app-domains', standalone: true, - imports: [CommonModule], + imports: [CommonModule, RouterLink], template: `
-

SSL Certificates

+
+

Domains

+ +
- @if (certificates().length > 0) { + @if (loading()) { +
+

Loading domains...

+
+ } @else if (domains().length > 0) {
- + + + - @for (cert of certificates(); track cert.domain) { - - - + @for (domainView of domains(); track domainView.domain.id) { + + + + + }
DomainIssuerProviderServicesCertificate Expiry Actions
{{ cert.domain }}{{ cert.issuer }}
+
{{ domainView.domain.domain }}
+ @if (domainView.domain.isObsolete) { + Obsolete + } +
+ @if (domainView.domain.dnsProvider === 'cloudflare') { + + Cloudflare + + } @else if (domainView.domain.dnsProvider === 'manual') { + + Manual + + } @else { + None + } + + {{ domainView.serviceCount }} + + @switch (domainView.certificateStatus) { + @case ('valid') { + + Valid + + } + @case ('expiring-soon') { + + Expiring Soon + + } + @case ('expired') { + + Expired + + } + @case ('pending') { + + Pending + + } + @default { + + None + + } + } + - - {{ formatDate(cert.expiryDate) }} - + @if (domainView.daysRemaining !== null) { + + {{ domainView.daysRemaining }} days + + } @else { + + } - + + View Details +
+ + +
+
+
Total Domains
+
{{ domains().length }}
+
+
+
Valid Certificates
+
{{ getStatusCount('valid') }}
+
+
+
Expiring Soon
+
{{ getStatusCount('expiring-soon') }}
+
+
+
Expired/Pending
+
{{ getStatusCount('expired') + getStatusCount('pending') }}
+
+
} @else {
-

No SSL certificates

-

Certificates are obtained automatically when deploying services with domains

+

No domains found

+

+ Sync your Cloudflare zones or manually add domains to get started +

+
}
`, }) -export class SslComponent implements OnInit { +export class DomainsComponent implements OnInit { private apiService = inject(ApiService); - certificates = signal([]); + private toastService = inject(ToastService); + + domains = signal([]); + loading = signal(true); + syncing = signal(false); ngOnInit(): void { - this.loadCertificates(); + this.loadDomains(); } - loadCertificates(): void { - this.apiService.getSslCertificates().subscribe({ + loadDomains(): void { + this.loading.set(true); + this.apiService.getDomains().subscribe({ next: (response) => { if (response.success && response.data) { - this.certificates.set(response.data); + this.domains.set(response.data); } + this.loading.set(false); + }, + error: () => { + this.loading.set(false); }, }); } - renewCertificate(cert: any): void { - this.apiService.renewSslCertificate(cert.domain).subscribe({ - next: () => { - alert('Certificate renewal initiated'); - this.loadCertificates(); + syncDomains(): void { + this.syncing.set(true); + this.apiService.syncCloudflareDomains().subscribe({ + next: (response) => { + if (response.success) { + this.toastService.success('Cloudflare domains synced successfully'); + this.loadDomains(); + } + this.syncing.set(false); + }, + error: (error) => { + this.toastService.error('Failed to sync Cloudflare domains: ' + (error.error?.error || error.message)); + this.syncing.set(false); }, }); } - formatDate(timestamp: number): string { - return new Date(timestamp).toLocaleDateString(); - } - - isExpiringSoon(timestamp: number): boolean { - const thirtyDays = 30 * 24 * 60 * 60 * 1000; - return timestamp - Date.now() < thirtyDays; + getStatusCount(status: string): number { + return this.domains().filter(d => d.certificateStatus === status).length; } } diff --git a/ui/src/app/features/services/service-create.component.ts b/ui/src/app/features/services/service-create.component.ts index 57afea8..d6b6990 100644 --- a/ui/src/app/features/services/service-create.component.ts +++ b/ui/src/app/features/services/service-create.component.ts @@ -1,7 +1,7 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, OnInit, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { Router } from '@angular/router'; +import { Router, RouterLink } from '@angular/router'; import { ApiService } from '../../core/services/api.service'; interface EnvVar { @@ -9,10 +9,16 @@ interface EnvVar { value: string; } +interface Domain { + domain: string; + dnsProvider: 'cloudflare' | 'manual' | null; + isObsolete: boolean; +} + @Component({ selector: 'app-service-create', standalone: true, - imports: [CommonModule, FormsModule], + imports: [CommonModule, FormsModule, RouterLink], template: `

Deploy New Service

@@ -42,13 +48,66 @@ interface EnvVar { id="image" [(ngModel)]="image" name="image" - required + [required]="!useOneboxRegistry" + [disabled]="useOneboxRegistry" placeholder="nginx:latest" class="input" />

Format: image:tag or registry/image:tag

+ +
+
+ + +
+

+ Store your container image in the local Onebox registry instead of using an external image. +

+ + @if (useOneboxRegistry) { +
+
+ + +

Tag to use (e.g., latest, v1.0, develop)

+
+ +
+ + +
+

+ Automatically pull and restart the service when a new image is pushed to the registry +

+
+ } +
+
@@ -71,11 +130,48 @@ interface EnvVar { type="text" id="domain" [(ngModel)]="domain" + (ngModelChange)="onDomainChange()" name="domain" placeholder="app.example.com" + list="domainList" class="input" + [class.border-red-300]="domainWarning()" /> -

Leave empty to skip automatic DNS & SSL

+ + @for (domain of availableDomains(); track domain.domain) { + + } + + + @if (domainWarning()) { +
+
+
+ + + +
+
+

+ {{ domainWarningTitle() }} +

+

{{ domainWarningMessage() }}

+ +
+
+
+ } @else { +

+ Leave empty to skip automatic DNS & SSL. + @if (availableDomains().length > 0) { + Or select from {{ availableDomains().length }} available domain(s). + } +

+ }
@@ -155,7 +251,7 @@ interface EnvVar {
`, }) -export class ServiceCreateComponent { +export class ServiceCreateComponent implements OnInit { private apiService = inject(ApiService); private router = inject(Router); @@ -169,6 +265,77 @@ export class ServiceCreateComponent { loading = signal(false); error = signal(''); + // Onebox Registry + useOneboxRegistry = false; + registryImageTag = 'latest'; + autoUpdateOnPush = false; + + // Domain validation + availableDomains = signal([]); + domainWarning = signal(false); + domainWarningTitle = signal(''); + domainWarningMessage = signal(''); + + ngOnInit(): void { + this.loadDomains(); + } + + loadDomains(): void { + this.apiService.getDomains().subscribe({ + next: (response) => { + if (response.success && response.data) { + const domains: Domain[] = response.data.map((d: any) => ({ + domain: d.domain.domain, + dnsProvider: d.domain.dnsProvider, + isObsolete: d.domain.isObsolete, + })); + this.availableDomains.set(domains); + } + }, + error: () => { + // Silently fail - domains list not critical + }, + }); + } + + onDomainChange(): void { + if (!this.domain) { + this.domainWarning.set(false); + return; + } + + // Extract base domain from entered domain + const parts = this.domain.split('.'); + if (parts.length < 2) { + // Not a valid domain format + this.domainWarning.set(false); + return; + } + + const baseDomain = parts.slice(-2).join('.'); + + // Check if base domain exists in available domains + const matchingDomain = this.availableDomains().find( + (d) => d.domain === baseDomain + ); + + if (!matchingDomain) { + this.domainWarning.set(true); + this.domainWarningTitle.set('Domain not found'); + this.domainWarningMessage.set( + `The base domain "${baseDomain}" is not in the Domain table. The service will deploy, but certificate management may not work. Sync your Cloudflare domains or manually add the domain first.` + ); + } else if (matchingDomain.isObsolete) { + this.domainWarning.set(true); + this.domainWarningTitle.set('Domain is obsolete'); + this.domainWarningMessage.set( + `The domain "${baseDomain}" is marked as obsolete (likely removed from Cloudflare). Certificate management may not work properly.` + ); + } else { + this.domainWarning.set(false); + } + } + addEnvVar(): void { this.envVars.update((vars) => [...vars, { key: '', value: '' }]); } @@ -197,6 +364,9 @@ export class ServiceCreateComponent { envVars: envVarsObj, autoDNS: this.autoDNS, autoSSL: this.autoSSL, + useOneboxRegistry: this.useOneboxRegistry, + registryImageTag: this.useOneboxRegistry ? this.registryImageTag : undefined, + autoUpdateOnPush: this.useOneboxRegistry ? this.autoUpdateOnPush : undefined, }; this.apiService.createService(data).subscribe({ diff --git a/ui/src/app/features/services/service-detail.component.ts b/ui/src/app/features/services/service-detail.component.ts index 2062b56..74de708 100644 --- a/ui/src/app/features/services/service-detail.component.ts +++ b/ui/src/app/features/services/service-detail.component.ts @@ -1,12 +1,25 @@ -import { Component, OnInit, inject, signal } from '@angular/core'; +import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { ActivatedRoute, Router } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ApiService, Service } from '../../core/services/api.service'; +import { ToastService } from '../../core/services/toast.service'; + +interface EnvVar { + key: string; + value: string; +} + +interface Domain { + domain: string; + dnsProvider: 'cloudflare' | 'manual' | null; + isObsolete: boolean; +} @Component({ selector: 'app-service-detail', standalone: true, - imports: [CommonModule], + imports: [CommonModule, FormsModule, RouterLink], template: `
@if (loading()) { @@ -29,109 +42,383 @@ import { ApiService, Service } from '../../core/services/api.service';
-

Service Details

-
-
-
Image
-
{{ service()!.image }}
-
-
-
Port
-
{{ service()!.port }}
-
- @if (service()!.domain) { - +
+

Service Details

+ @if (!isEditing()) { + } - @if (service()!.containerID) { -
-
Container ID
-
{{ service()!.containerID?.substring(0, 12) }}
-
- } -
-
Created
-
{{ formatDate(service()!.createdAt) }}
-
-
-
Updated
-
{{ formatDate(service()!.updatedAt) }}
-
-
+
- - @if (Object.keys(service()!.envVars).length > 0) { -
-

Environment Variables

-
- @for (entry of Object.entries(service()!.envVars); track entry[0]) { -
- {{ entry[0] }} - {{ entry[1] }} + @if (!isEditing()) { +
+
+
Image
+
{{ service()!.image }}
+
+
+
Port
+
{{ service()!.port }}
+
+ @if (service()!.domain) { + + } + @if (service()!.containerID) { +
+
Container ID
+
{{ service()!.containerID?.substring(0, 12) }}
+
+ } +
+
Created
+
{{ formatDate(service()!.createdAt) }}
+
+
+
Updated
+
{{ formatDate(service()!.updatedAt) }}
+
+
+ + + @if (service()!.useOneboxRegistry) { +
+

Onebox Registry

+
+
+
Repository
+
{{ service()!.registryRepository }}
+
+
Tag
+
{{ service()!.registryImageTag || 'latest' }}
+
+ @if (service()!.registryToken) { +
+
Push/Pull Token
+
+
+ + +
+

+ Use this token to push images: docker login -u unused -p [token] {{ registryBaseUrl() }} +

+
+
+ } +
+
Auto-update
+
+ {{ service()!.autoUpdateOnPush ? 'Enabled' : 'Disabled' }} +
+
+ @if (service()!.imageDigest) { +
+
Current Digest
+
{{ service()!.imageDigest }}
+
+ } +
+
+ } + + + @if (Object.keys(service()!.envVars).length > 0) { +
+

Environment Variables

+
+ @for (entry of Object.entries(service()!.envVars); track entry[0]) { +
+ {{ entry[0] }} + {{ entry[1] }} +
+ } +
+
+ } + } @else { + +
+ +
+ + +

Format: image:tag or registry/image:tag

+
+ + +
+ + +

Port that your application listens on

+
+ + +
+ + + + @for (domain of availableDomains(); track domain.domain) { + + } + + + @if (domainWarning()) { +
+
+
+ + + +
+
+

+ {{ domainWarningTitle() }} +

+

{{ domainWarningMessage() }}

+ +
+
+
+ } @else { +

+ Leave empty to skip automatic DNS & SSL. + @if (availableDomains().length > 0) { + Or select from {{ availableDomains().length }} available domain(s). + } +

}
-
+ + +
+ + @for (env of editEnvVars(); track $index) { +
+ + + +
+ } + +
+ + @if (error()) { +
+

{{ error() }}

+
+ } + + +
+ + +
+ }
-
-

Actions

-
- @if (service()!.status === 'stopped') { - - } - @if (service()!.status === 'running') { - - - } - + @if (!isEditing()) { +
+

Actions

+
+ @if (service()!.status === 'stopped') { + + } + @if (service()!.status === 'running') { + + + } + +
-
+ } -
-
-

Logs

- + @if (!isEditing()) { +
+
+

Logs

+
+ + + + + + + + + + +
+
+ @if (loadingLogs()) { +
+
+
+ } @else { +
+ @if (filteredLogs().length === 0) { +

No logs available

+ } @else { + @for (line of filteredLogs(); track $index) { +
{{ line }}
+ } + } +
+ } + @if (filteredLogs().length > 0 && filteredLogs().length !== logLines().length) { +
+ Showing {{ filteredLogs().length }} of {{ logLines().length }} lines +
+ }
- @if (loadingLogs()) { -
-
-
- } @else { -
-
{{ logs() || 'No logs available' }}
-
- } -
+ } }
`, }) -export class ServiceDetailComponent implements OnInit { +export class ServiceDetailComponent implements OnInit, OnDestroy { private apiService = inject(ApiService); private route = inject(ActivatedRoute); private router = inject(Router); service = signal(null); logs = signal(''); + logLines = signal([]); + filteredLogs = signal([]); + logSearch = ''; + logLevelFilter = 'all'; + logsAutoRefresh = false; + private logsRefreshInterval?: number; loading = signal(true); loadingLogs = signal(false); + // Edit mode + isEditing = signal(false); + saving = signal(false); + error = signal(''); + editForm = { + image: '', + port: 80, + domain: '', + }; + editEnvVars = signal([]); + + // Domain validation + availableDomains = signal([]); + domainWarning = signal(false); + domainWarningTitle = signal(''); + domainWarningMessage = signal(''); + Object = Object; ngOnInit(): void { const name = this.route.snapshot.paramMap.get('name')!; this.loadService(name); this.loadLogs(name); + this.loadDomains(); } loadService(name: string): void { @@ -156,6 +443,9 @@ export class ServiceDetailComponent implements OnInit { next: (response) => { if (response.success && response.data) { this.logs.set(response.data); + const lines = response.data.split('\n').filter((line: string) => line.trim()); + this.logLines.set(lines); + this.filterLogs(); } this.loadingLogs.set(false); }, @@ -165,6 +455,174 @@ export class ServiceDetailComponent implements OnInit { }); } + filterLogs(): void { + let lines = this.logLines(); + + // Apply level filter + if (this.logLevelFilter !== 'all') { + lines = lines.filter(line => this.isLogLevel(line, this.logLevelFilter)); + } + + // Apply search filter + if (this.logSearch.trim()) { + const searchLower = this.logSearch.toLowerCase(); + lines = lines.filter(line => line.toLowerCase().includes(searchLower)); + } + + this.filteredLogs.set(lines); + } + + isLogLevel(line: string, level: string): boolean { + const lineLower = line.toLowerCase(); + if (level === 'error') return lineLower.includes('error') || lineLower.includes('✖'); + if (level === 'warn') return lineLower.includes('warn') || lineLower.includes('warning'); + if (level === 'info') return lineLower.includes('info') || lineLower.includes('ℹ'); + if (level === 'debug') return lineLower.includes('debug'); + return false; + } + + hasLogLevel(line: string): boolean { + return this.isLogLevel(line, 'error') || + this.isLogLevel(line, 'warn') || + this.isLogLevel(line, 'info') || + this.isLogLevel(line, 'debug'); + } + + toggleLogsAutoRefresh(): void { + if (this.logsAutoRefresh) { + this.logsRefreshInterval = window.setInterval(() => { + this.refreshLogs(); + }, 5000); // Refresh every 5 seconds + } else { + if (this.logsRefreshInterval) { + clearInterval(this.logsRefreshInterval); + this.logsRefreshInterval = undefined; + } + } + } + + loadDomains(): void { + this.apiService.getDomains().subscribe({ + next: (response) => { + if (response.success && response.data) { + const domains: Domain[] = response.data.map((d: any) => ({ + domain: d.domain.domain, + dnsProvider: d.domain.dnsProvider, + isObsolete: d.domain.isObsolete, + })); + this.availableDomains.set(domains); + } + }, + error: () => { + // Silently fail - domains list not critical + }, + }); + } + + startEditing(): void { + const svc = this.service()!; + this.editForm.image = svc.image; + this.editForm.port = svc.port; + this.editForm.domain = svc.domain || ''; + + // Convert env vars to array + const envVars: EnvVar[] = []; + for (const [key, value] of Object.entries(svc.envVars || {})) { + envVars.push({ key, value }); + } + this.editEnvVars.set(envVars); + + this.isEditing.set(true); + this.error.set(''); + } + + cancelEditing(): void { + this.isEditing.set(false); + this.error.set(''); + this.domainWarning.set(false); + } + + saveService(): void { + this.error.set(''); + this.saving.set(true); + + // Convert env vars to object + const envVarsObj: Record = {}; + for (const env of this.editEnvVars()) { + if (env.key && env.value) { + envVarsObj[env.key] = env.value; + } + } + + const updates = { + image: this.editForm.image, + port: this.editForm.port, + domain: this.editForm.domain || undefined, + envVars: envVarsObj, + }; + + this.apiService.updateService(this.service()!.name, updates).subscribe({ + next: (response) => { + this.saving.set(false); + if (response.success) { + this.service.set(response.data!); + this.isEditing.set(false); + } else { + this.error.set(response.error || 'Failed to update service'); + } + }, + error: (err) => { + this.saving.set(false); + this.error.set(err.error?.error || 'An error occurred'); + }, + }); + } + + addEnvVar(): void { + this.editEnvVars.update((vars) => [...vars, { key: '', value: '' }]); + } + + removeEnvVar(index: number): void { + this.editEnvVars.update((vars) => vars.filter((_, i) => i !== index)); + } + + onDomainChange(): void { + if (!this.editForm.domain) { + this.domainWarning.set(false); + return; + } + + // Extract base domain from entered domain + const parts = this.editForm.domain.split('.'); + if (parts.length < 2) { + this.domainWarning.set(false); + return; + } + + const baseDomain = parts.slice(-2).join('.'); + + // Check if base domain exists in available domains + const matchingDomain = this.availableDomains().find( + (d) => d.domain === baseDomain + ); + + if (!matchingDomain) { + this.domainWarning.set(true); + this.domainWarningTitle.set('Domain not found'); + this.domainWarningMessage.set( + `The base domain "${baseDomain}" is not in the Domain table. The service will update, but certificate management may not work. Sync your Cloudflare domains or manually add the domain first.` + ); + } else if (matchingDomain.isObsolete) { + this.domainWarning.set(true); + this.domainWarningTitle.set('Domain is obsolete'); + this.domainWarningMessage.set( + `The domain "${baseDomain}" is marked as obsolete (likely removed from Cloudflare). Certificate management may not work properly.` + ); + } else { + this.domainWarning.set(false); + } + } + refreshLogs(): void { this.loadLogs(this.service()!.name); } @@ -206,4 +664,22 @@ export class ServiceDetailComponent implements OnInit { formatDate(timestamp: number): string { return new Date(timestamp).toLocaleString(); } + + private toastService = inject(ToastService); + + copyToken(token: string): void { + navigator.clipboard.writeText(token).then(() => { + this.toastService.success('Token copied to clipboard!'); + }).catch(() => { + this.toastService.error('Failed to copy token'); + }); + } + + registryBaseUrl = signal('localhost:5000'); + + ngOnDestroy(): void { + if (this.logsRefreshInterval) { + clearInterval(this.logsRefreshInterval); + } + } } diff --git a/ui/src/app/features/settings/settings.component.ts b/ui/src/app/features/settings/settings.component.ts index 224359a..20e3549 100644 --- a/ui/src/app/features/settings/settings.component.ts +++ b/ui/src/app/features/settings/settings.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ApiService } from '../../core/services/api.service'; +import { ToastService } from '../../core/services/toast.service'; @Component({ selector: 'app-settings', @@ -67,6 +68,7 @@ import { ApiService } from '../../core/services/api.service'; }) export class SettingsComponent implements OnInit { private apiService = inject(ApiService); + private toastService = inject(ToastService); settings: any = {}; ngOnInit(): void { @@ -90,7 +92,9 @@ export class SettingsComponent implements OnInit { ); Promise.all(promises).then(() => { - alert('Settings saved successfully'); + this.toastService.success('Settings saved successfully'); + }).catch((error) => { + this.toastService.error('Failed to save settings: ' + (error.message || 'Unknown error')); }); } } diff --git a/ui/src/app/shared/components/layout.component.ts b/ui/src/app/shared/components/layout.component.ts index 924af36..3c632de 100644 --- a/ui/src/app/shared/components/layout.component.ts +++ b/ui/src/app/shared/components/layout.component.ts @@ -2,11 +2,12 @@ import { Component, inject } from '@angular/core'; import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { CommonModule } from '@angular/common'; import { AuthService } from '../../core/services/auth.service'; +import { ToastComponent } from './toast.component'; @Component({ selector: 'app-layout', standalone: true, - imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive], + imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive, ToastComponent], template: `
@@ -48,11 +49,11 @@ import { AuthService } from '../../core/services/auth.service'; DNS - SSL + Domains + + +
`, }) diff --git a/ui/src/app/shared/components/loading-spinner.component.ts b/ui/src/app/shared/components/loading-spinner.component.ts new file mode 100644 index 0000000..35d31ca --- /dev/null +++ b/ui/src/app/shared/components/loading-spinner.component.ts @@ -0,0 +1,48 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-loading-spinner', + standalone: true, + imports: [CommonModule], + template: ` +
+
+ @if (text) { + {{ text }} + } +
+ `, + styles: [` + @keyframes spin { + to { + transform: rotate(360deg); + } + } + + .animate-spin { + animation: spin 1s linear infinite; + } + + .border-3 { + border-width: 3px; + } + `] +}) +export class LoadingSpinnerComponent { + @Input() size: 'sm' | 'md' | 'lg' | 'xl' = 'md'; + @Input() color: 'primary' | 'white' | 'gray' = 'primary'; + @Input() text?: string; + @Input() containerClass?: string; +} diff --git a/ui/src/app/shared/components/toast.component.ts b/ui/src/app/shared/components/toast.component.ts new file mode 100644 index 0000000..97760c0 --- /dev/null +++ b/ui/src/app/shared/components/toast.component.ts @@ -0,0 +1,91 @@ +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ToastService } from '../../core/services/toast.service'; + +@Component({ + selector: 'app-toast', + standalone: true, + imports: [CommonModule], + template: ` +
+ @for (toast of toastService.toasts(); track toast.id) { +
+ +
+ @if (toast.type === 'success') { + + + + } + @if (toast.type === 'error') { + + + + } + @if (toast.type === 'info') { + + + + } + @if (toast.type === 'warning') { + + + + } +
+ + +
+ {{ toast.message }} +
+ + + +
+ } +
+ `, + styles: [` + @keyframes slide-in-right { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } + } + + .animate-slide-in-right { + animation: slide-in-right 0.3s ease-out; + } + + .toast-item { + transition: all 0.3s ease-out; + } + + .toast-item:hover { + transform: translateY(-2px); + } + `] +}) +export class ToastComponent { + toastService = inject(ToastService); +}