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.
This commit is contained in:
2025-11-24 01:31:15 +00:00
parent b6ac4f209a
commit c9beae93c8
23 changed files with 2475 additions and 130 deletions

View File

@@ -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"],

View File

@@ -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 {

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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
*/

View File

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

View File

@@ -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<Response> {
try {
const body = await req.json();
const updates: {
image?: string;
registry?: string;
port?: number;
domain?: string;
envVars?: Record<string, string>;
} = {};
// 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<Response> {
try {
await this.oneboxRef.services.removeService(name);
@@ -659,6 +712,87 @@ export class OneboxHttpServer {
}
}
private async handleGetDnsRecordsRequest(): Promise<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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
*/

View File

@@ -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) {

237
ts/classes/registry.ts Normal file
View File

@@ -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<void> {
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<Response> {
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<string> {
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<string[]> {
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<string | null> {
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<void> {
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<void> {
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');
}
}

View File

@@ -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<string, string>;
}
): Promise<IService> {
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<void> {
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}`);
}
}
}
}

View File

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

View File

@@ -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

View File

@@ -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',

View File

@@ -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<ApiResponse<string>>(`${this.baseUrl}/services/${name}/logs`);
}
updateService(name: string, updates: {
image?: string;
registry?: string;
port?: number;
domain?: string;
envVars?: Record<string, string>;
}): Observable<ApiResponse<Service>> {
return this.http.put<ApiResponse<Service>>(`${this.baseUrl}/services/${name}`, updates);
}
// Registries
getRegistries(): Observable<ApiResponse<Registry[]>> {
return this.http.get<ApiResponse<Registry[]>>(`${this.baseUrl}/registries`);
@@ -132,6 +149,10 @@ export class ApiService {
return this.http.delete<ApiResponse>(`${this.baseUrl}/dns/${domain}`);
}
syncDnsRecords(): Observable<ApiResponse> {
return this.http.post<ApiResponse>(`${this.baseUrl}/dns/sync`, {});
}
// SSL
getSslCertificates(): Observable<ApiResponse<any[]>> {
return this.http.get<ApiResponse<any[]>>(`${this.baseUrl}/ssl`);
@@ -141,6 +162,19 @@ export class ApiService {
return this.http.post<ApiResponse>(`${this.baseUrl}/ssl/${domain}/renew`, {});
}
// Domains
getDomains(): Observable<ApiResponse<any[]>> {
return this.http.get<ApiResponse<any[]>>(`${this.baseUrl}/domains`);
}
getDomainDetail(domain: string): Observable<ApiResponse<any>> {
return this.http.get<ApiResponse<any>>(`${this.baseUrl}/domains/${domain}`);
}
syncCloudflareDomains(): Observable<ApiResponse> {
return this.http.post<ApiResponse>(`${this.baseUrl}/domains/sync`, {});
}
// Settings
getSettings(): Observable<ApiResponse<Record<string, string>>> {
return this.http.get<ApiResponse<Record<string, string>>>(`${this.baseUrl}/settings`);

View File

@@ -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<Toast[]>([]);
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([]);
}
}

View File

@@ -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: `
<div class="px-4 sm:px-0">
<h1 class="text-3xl font-bold text-gray-900 mb-8">Dashboard</h1>
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
<div class="flex items-center gap-4">
@if (lastUpdated()) {
<span class="text-sm text-gray-500">
Last updated: {{ lastUpdated()!.toLocaleTimeString() }}
</span>
}
<button (click)="refresh()" class="btn btn-secondary text-sm" [disabled]="loading()">
<svg class="w-4 h-4 mr-1" [class.animate-spin]="loading()" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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"></path>
</svg>
Refresh
</button>
</div>
</div>
@if (loading()) {
<div class="text-center py-12">
@@ -169,14 +186,38 @@ import { ApiService, SystemStatus } from '../../core/services/api.service';
</div>
`,
})
export class DashboardComponent implements OnInit {
export class DashboardComponent implements OnInit, OnDestroy {
private apiService = inject(ApiService);
private wsService = inject(WebSocketService);
status = signal<SystemStatus | null>(null);
loading = signal(true);
lastUpdated = signal<Date | null>(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();
}
}

View File

@@ -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: `
<div class="px-4 sm:px-0">
<h1 class="text-3xl font-bold text-gray-900 mb-8">DNS Records</h1>
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">DNS Records</h1>
<button
(click)="syncRecords()"
[disabled]="syncing()"
class="btn btn-primary"
>
{{ syncing() ? 'Syncing...' : 'Sync Cloudflare' }}
</button>
</div>
@if (records().length > 0) {
<div class="card overflow-hidden p-0">
@@ -40,6 +50,7 @@ import { ApiService } from '../../core/services/api.service';
<div class="card text-center py-12">
<p class="text-gray-500">No DNS records configured</p>
<p class="text-sm text-gray-400 mt-2">DNS records are created automatically when deploying services with domains</p>
<p class="text-sm text-gray-400 mt-2">Or click "Sync Cloudflare" to import existing DNS records from Cloudflare</p>
</div>
}
</div>
@@ -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<any[]>([]);
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({

View File

@@ -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: `
<div class="px-4 sm:px-0">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center mb-4">
<a
routerLink="/domains"
class="text-primary-600 hover:text-primary-900 mr-4"
>
← Back to Domains
</a>
</div>
@if (loading()) {
<div class="card text-center py-12">
<p class="text-gray-500">Loading domain details...</p>
</div>
} @else if (domainDetail()) {
<div>
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold text-gray-900">
{{ domainDetail()!.domain.domain }}
</h1>
<div class="mt-2 flex items-center gap-3">
@if (domainDetail()!.domain.dnsProvider === 'cloudflare') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Cloudflare
</span>
} @else if (domainDetail()!.domain.dnsProvider === 'manual') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Manual DNS
</span>
}
@if (domainDetail()!.domain.defaultWildcard) {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
Wildcard Enabled
</span>
}
@if (domainDetail()!.domain.isObsolete) {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Obsolete
</span>
}
</div>
</div>
</div>
<!-- Stats Grid -->
<div class="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-3">
<div class="card">
<dt class="text-sm font-medium text-gray-500 truncate">Certificates</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">
{{ domainDetail()!.certificates.length }}
</dd>
</div>
<div class="card">
<dt class="text-sm font-medium text-gray-500 truncate">Requirements</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">
{{ domainDetail()!.requirements.length }}
</dd>
</div>
<div class="card">
<dt class="text-sm font-medium text-gray-500 truncate">Services</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">
{{ domainDetail()!.services.length }}
</dd>
</div>
</div>
<!-- Certificates Section -->
<div class="mt-8">
<h2 class="text-xl font-bold text-gray-900 mb-4">SSL Certificates</h2>
@if (domainDetail()!.certificates.length > 0) {
<div class="card overflow-hidden p-0">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Expires</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Issuer</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@for (cert of domainDetail()!.certificates; track cert.id) {
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{{ cert.certDomain }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if (cert.isWildcard) {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
Wildcard
</span>
} @else {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Standard
</span>
}
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if (getCertStatus(cert) === 'valid') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Valid
</span>
} @else if (getCertStatus(cert) === 'expiring') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
Expiring Soon
</span>
} @else {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Expired/Invalid
</span>
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ formatDate(cert.expiryDate) }}
<span class="text-gray-500">({{ getDaysRemaining(cert.expiryDate) }} days)</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ cert.issuer }}
</td>
</tr>
}
</tbody>
</table>
</div>
} @else {
<div class="card text-center py-8">
<p class="text-gray-500">No certificates for this domain</p>
</div>
}
</div>
<!-- Certificate Requirements Section -->
<div class="mt-8">
<h2 class="text-xl font-bold text-gray-900 mb-4">Certificate Requirements</h2>
@if (domainDetail()!.requirements.length > 0) {
<div class="card overflow-hidden p-0">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Service</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Subdomain</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Certificate ID</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@for (req of domainDetail()!.requirements; track req.id) {
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ getServiceName(req.serviceId) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ req.subdomain || '(root)' }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if (req.status === 'active') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Active
</span>
} @else if (req.status === 'pending') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
Pending
</span>
} @else if (req.status === 'renewing') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Renewing
</span>
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ req.certificateId || '—' }}
</td>
</tr>
}
</tbody>
</table>
</div>
} @else {
<div class="card text-center py-8">
<p class="text-gray-500">No certificate requirements</p>
</div>
}
</div>
<!-- Services Section -->
<div class="mt-8">
<h2 class="text-xl font-bold text-gray-900 mb-4">Services Using This Domain</h2>
@if (domainDetail()!.services.length > 0) {
<div class="card overflow-hidden p-0">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@for (service of domainDetail()!.services; track service.id) {
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{{ service.name }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ service.domain }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if (service.status === 'running') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Running
</span>
} @else if (service.status === 'stopped') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Stopped
</span>
} @else {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
{{ service.status }}
</span>
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
<a
[routerLink]="['/services', service.name]"
class="text-primary-600 hover:text-primary-900"
>
View Service
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
} @else {
<div class="card text-center py-8">
<p class="text-gray-500">No services using this domain</p>
</div>
}
</div>
</div>
} @else {
<div class="card text-center py-12">
<p class="text-gray-500">Domain not found</p>
</div>
}
</div>
</div>
`,
})
export class DomainDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
private apiService = inject(ApiService);
domainDetail = signal<DomainDetail | null>(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}`;
}
}

View File

@@ -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: `
<div class="px-4 sm:px-0">
<h1 class="text-3xl font-bold text-gray-900 mb-8">SSL Certificates</h1>
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">Domains</h1>
<button
(click)="syncDomains()"
[disabled]="syncing()"
class="btn btn-primary"
>
{{ syncing() ? 'Syncing...' : 'Sync Cloudflare' }}
</button>
</div>
@if (certificates().length > 0) {
@if (loading()) {
<div class="card text-center py-12">
<p class="text-gray-500">Loading domains...</p>
</div>
} @else if (domains().length > 0) {
<div class="card overflow-hidden p-0">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Issuer</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Provider</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Services</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Certificate</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Expiry</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@for (cert of certificates(); track cert.domain) {
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ cert.domain }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ cert.issuer }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span [ngClass]="isExpiringSoon(cert.expiryDate) ? 'text-red-600' : 'text-gray-500'">
{{ formatDate(cert.expiryDate) }}
@for (domainView of domains(); track domainView.domain.id) {
<tr [class.opacity-50]="domainView.domain.isObsolete">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ domainView.domain.domain }}</div>
@if (domainView.domain.isObsolete) {
<span class="text-xs text-red-600">Obsolete</span>
}
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if (domainView.domain.dnsProvider === 'cloudflare') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Cloudflare
</span>
} @else if (domainView.domain.dnsProvider === 'manual') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Manual
</span>
} @else {
<span class="text-sm text-gray-400">None</span>
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ domainView.serviceCount }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
@switch (domainView.certificateStatus) {
@case ('valid') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Valid
</span>
}
@case ('expiring-soon') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
Expiring Soon
</span>
}
@case ('expired') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Expired
</span>
}
@case ('pending') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Pending
</span>
}
@default {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
None
</span>
}
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
@if (domainView.daysRemaining !== null) {
<span [ngClass]="domainView.daysRemaining <= 30 ? 'text-red-600 font-medium' : 'text-gray-500'">
{{ domainView.daysRemaining }} days
</span>
} @else {
<span class="text-gray-400">—</span>
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
<button (click)="renewCertificate(cert)" class="text-primary-600 hover:text-primary-900">Renew</button>
<a
[routerLink]="['/domains', domainView.domain.domain]"
class="text-primary-600 hover:text-primary-900"
>
View Details
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Summary Stats -->
<div class="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-4">
<div class="card">
<dt class="text-sm font-medium text-gray-500 truncate">Total Domains</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">{{ domains().length }}</dd>
</div>
<div class="card">
<dt class="text-sm font-medium text-gray-500 truncate">Valid Certificates</dt>
<dd class="mt-1 text-3xl font-semibold text-green-600">{{ getStatusCount('valid') }}</dd>
</div>
<div class="card">
<dt class="text-sm font-medium text-gray-500 truncate">Expiring Soon</dt>
<dd class="mt-1 text-3xl font-semibold text-yellow-600">{{ getStatusCount('expiring-soon') }}</dd>
</div>
<div class="card">
<dt class="text-sm font-medium text-gray-500 truncate">Expired/Pending</dt>
<dd class="mt-1 text-3xl font-semibold text-red-600">{{ getStatusCount('expired') + getStatusCount('pending') }}</dd>
</div>
</div>
} @else {
<div class="card text-center py-12">
<p class="text-gray-500">No SSL certificates</p>
<p class="text-sm text-gray-400 mt-2">Certificates are obtained automatically when deploying services with domains</p>
<p class="text-gray-500">No domains found</p>
<p class="text-sm text-gray-400 mt-2">
Sync your Cloudflare zones or manually add domains to get started
</p>
<button
(click)="syncDomains()"
class="mt-4 btn btn-primary"
>
Sync Cloudflare Domains
</button>
</div>
}
</div>
`,
})
export class SslComponent implements OnInit {
export class DomainsComponent implements OnInit {
private apiService = inject(ApiService);
certificates = signal<any[]>([]);
private toastService = inject(ToastService);
domains = signal<DomainView[]>([]);
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;
}
}

View File

@@ -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: `
<div class="px-4 sm:px-0">
<h1 class="text-3xl font-bold text-gray-900 mb-8">Deploy New Service</h1>
@@ -42,13 +48,66 @@ interface EnvVar {
id="image"
[(ngModel)]="image"
name="image"
required
[required]="!useOneboxRegistry"
[disabled]="useOneboxRegistry"
placeholder="nginx:latest"
class="input"
/>
<p class="mt-1 text-sm text-gray-500">Format: image:tag or registry/image:tag</p>
</div>
<!-- Onebox Registry Option -->
<div class="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-center mb-2">
<input
type="checkbox"
id="useOneboxRegistry"
[(ngModel)]="useOneboxRegistry"
name="useOneboxRegistry"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label for="useOneboxRegistry" class="ml-2 block text-sm font-medium text-gray-900">
Use Onebox Registry
</label>
</div>
<p class="text-sm text-gray-600 mb-3">
Store your container image in the local Onebox registry instead of using an external image.
</p>
@if (useOneboxRegistry) {
<div class="space-y-3">
<div>
<label for="registryImageTag" class="label text-sm">Image Tag</label>
<input
type="text"
id="registryImageTag"
[(ngModel)]="registryImageTag"
name="registryImageTag"
placeholder="latest"
class="input text-sm"
/>
<p class="mt-1 text-xs text-gray-500">Tag to use (e.g., latest, v1.0, develop)</p>
</div>
<div class="flex items-center">
<input
type="checkbox"
id="autoUpdateOnPush"
[(ngModel)]="autoUpdateOnPush"
name="autoUpdateOnPush"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label for="autoUpdateOnPush" class="ml-2 block text-sm text-gray-700">
Auto-restart on new image push
</label>
</div>
<p class="text-xs text-gray-500 ml-6">
Automatically pull and restart the service when a new image is pushed to the registry
</p>
</div>
}
</div>
<!-- Port -->
<div class="mb-6">
<label for="port" class="label">Container Port *</label>
@@ -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()"
/>
<p class="mt-1 text-sm text-gray-500">Leave empty to skip automatic DNS & SSL</p>
<datalist id="domainList">
@for (domain of availableDomains(); track domain.domain) {
<option [value]="domain.domain">{{ domain.domain }}</option>
}
</datalist>
@if (domainWarning()) {
<div class="mt-2 rounded-md bg-yellow-50 p-3">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-800">
<strong>{{ domainWarningTitle() }}</strong>
</p>
<p class="mt-1 text-sm text-yellow-700">{{ domainWarningMessage() }}</p>
<div class="mt-2">
<a routerLink="/domains" class="text-sm font-medium text-yellow-800 hover:text-yellow-900 underline">
View domains &rarr;
</a>
</div>
</div>
</div>
</div>
} @else {
<p class="mt-1 text-sm text-gray-500">
Leave empty to skip automatic DNS & SSL.
@if (availableDomains().length > 0) {
<span>Or select from {{ availableDomains().length }} available domain(s).</span>
}
</p>
}
</div>
<!-- Environment Variables -->
@@ -155,7 +251,7 @@ interface EnvVar {
</div>
`,
})
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<Domain[]>([]);
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({

View File

@@ -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: `
<div class="px-4 sm:px-0">
@if (loading()) {
@@ -29,7 +42,16 @@ import { ApiService, Service } from '../../core/services/api.service';
<!-- Details Card -->
<div class="card mb-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">Service Details</h2>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-medium text-gray-900">Service Details</h2>
@if (!isEditing()) {
<button (click)="startEditing()" class="btn btn-secondary text-sm">
Edit Service
</button>
}
</div>
@if (!isEditing()) {
<dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-gray-500">Image</dt>
@@ -65,6 +87,61 @@ import { ApiService, Service } from '../../core/services/api.service';
</div>
</dl>
<!-- Registry Information -->
@if (service()!.useOneboxRegistry) {
<div class="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 class="text-sm font-semibold text-blue-900 mb-3">Onebox Registry</h3>
<dl class="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-blue-700">Repository</dt>
<dd class="mt-1 text-sm text-blue-900 font-mono">{{ service()!.registryRepository }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-blue-700">Tag</dt>
<dd class="mt-1 text-sm text-blue-900">{{ service()!.registryImageTag || 'latest' }}</dd>
</div>
@if (service()!.registryToken) {
<div class="sm:col-span-2">
<dt class="text-sm font-medium text-blue-700">Push/Pull Token</dt>
<dd class="mt-1">
<div class="flex items-center gap-2">
<input
type="password"
[value]="service()!.registryToken"
readonly
class="input text-xs font-mono flex-1"
#tokenInput
/>
<button
type="button"
(click)="copyToken(tokenInput.value)"
class="btn btn-secondary text-xs"
>
Copy
</button>
</div>
<p class="mt-1 text-xs text-blue-600">
Use this token to push images: <code class="bg-blue-100 px-1 py-0.5 rounded">docker login -u unused -p [token] {{ registryBaseUrl() }}</code>
</p>
</dd>
</div>
}
<div>
<dt class="text-sm font-medium text-blue-700">Auto-update</dt>
<dd class="mt-1 text-sm text-blue-900">
{{ service()!.autoUpdateOnPush ? 'Enabled' : 'Disabled' }}
</dd>
</div>
@if (service()!.imageDigest) {
<div class="sm:col-span-2">
<dt class="text-sm font-medium text-blue-700">Current Digest</dt>
<dd class="mt-1 text-xs text-blue-900 font-mono break-all">{{ service()!.imageDigest }}</dd>
</div>
}
</dl>
</div>
}
<!-- Environment Variables -->
@if (Object.keys(service()!.envVars).length > 0) {
<div class="mt-6">
@@ -79,9 +156,140 @@ import { ApiService, Service } from '../../core/services/api.service';
</div>
</div>
}
} @else {
<!-- Edit Form -->
<form (ngSubmit)="saveService()">
<!-- Image -->
<div class="mb-6">
<label for="edit-image" class="label">Docker Image *</label>
<input
type="text"
id="edit-image"
[(ngModel)]="editForm.image"
name="image"
required
placeholder="nginx:latest"
class="input"
/>
<p class="mt-1 text-sm text-gray-500">Format: image:tag or registry/image:tag</p>
</div>
<!-- Port -->
<div class="mb-6">
<label for="edit-port" class="label">Container Port *</label>
<input
type="number"
id="edit-port"
[(ngModel)]="editForm.port"
name="port"
required
placeholder="80"
class="input"
/>
<p class="mt-1 text-sm text-gray-500">Port that your application listens on</p>
</div>
<!-- Domain -->
<div class="mb-6">
<label for="edit-domain" class="label">Domain (Optional)</label>
<input
type="text"
id="edit-domain"
[(ngModel)]="editForm.domain"
(ngModelChange)="onDomainChange()"
name="domain"
placeholder="app.example.com"
list="domainList"
class="input"
[class.border-red-300]="domainWarning()"
/>
<datalist id="domainList">
@for (domain of availableDomains(); track domain.domain) {
<option [value]="domain.domain">{{ domain.domain }}</option>
}
</datalist>
@if (domainWarning()) {
<div class="mt-2 rounded-md bg-yellow-50 p-3">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-800">
<strong>{{ domainWarningTitle() }}</strong>
</p>
<p class="mt-1 text-sm text-yellow-700">{{ domainWarningMessage() }}</p>
<div class="mt-2">
<a routerLink="/domains" class="text-sm font-medium text-yellow-800 hover:text-yellow-900 underline">
View domains &rarr;
</a>
</div>
</div>
</div>
</div>
} @else {
<p class="mt-1 text-sm text-gray-500">
Leave empty to skip automatic DNS & SSL.
@if (availableDomains().length > 0) {
<span>Or select from {{ availableDomains().length }} available domain(s).</span>
}
</p>
}
</div>
<!-- Environment Variables -->
<div class="mb-6">
<label class="label">Environment Variables</label>
@for (env of editEnvVars(); track $index) {
<div class="flex gap-2 mb-2">
<input
type="text"
[(ngModel)]="env.key"
[name]="'envKey' + $index"
placeholder="KEY"
class="input flex-1"
/>
<input
type="text"
[(ngModel)]="env.value"
[name]="'envValue' + $index"
placeholder="value"
class="input flex-1"
/>
<button type="button" (click)="removeEnvVar($index)" class="btn btn-danger">
Remove
</button>
</div>
}
<button type="button" (click)="addEnvVar()" class="btn btn-secondary mt-2">
Add Environment Variable
</button>
</div>
@if (error()) {
<div class="rounded-md bg-red-50 p-4 mb-6">
<p class="text-sm text-red-800">{{ error() }}</p>
</div>
}
<!-- Edit Actions -->
<div class="flex justify-end space-x-4">
<button type="button" (click)="cancelEditing()" class="btn btn-secondary" [disabled]="saving()">
Cancel
</button>
<button type="submit" class="btn btn-primary" [disabled]="saving()">
{{ saving() ? 'Saving...' : 'Save Changes' }}
</button>
</div>
</form>
}
</div>
<!-- Actions -->
@if (!isEditing()) {
<div class="card mb-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">Actions</h2>
<div class="flex space-x-4">
@@ -95,43 +303,122 @@ import { ApiService, Service } from '../../core/services/api.service';
<button (click)="deleteService()" class="btn btn-danger">Delete</button>
</div>
</div>
}
<!-- Logs -->
@if (!isEditing()) {
<div class="card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-medium text-gray-900">Logs</h2>
<button (click)="refreshLogs()" class="btn btn-secondary text-sm">Refresh</button>
<div class="flex items-center gap-3">
<!-- Search -->
<input
type="text"
[(ngModel)]="logSearch"
(ngModelChange)="filterLogs()"
placeholder="Search logs..."
class="input text-sm w-48"
/>
<!-- Log Level Filter -->
<select [(ngModel)]="logLevelFilter" (ngModelChange)="filterLogs()" class="input text-sm">
<option value="all">All Levels</option>
<option value="error">Errors</option>
<option value="warn">Warnings</option>
<option value="info">Info</option>
<option value="debug">Debug</option>
</select>
<!-- Auto-refresh toggle -->
<label class="flex items-center text-sm text-gray-700">
<input
type="checkbox"
[(ngModel)]="logsAutoRefresh"
(ngModelChange)="toggleLogsAutoRefresh()"
class="mr-2"
/>
Auto-refresh
</label>
<button (click)="refreshLogs()" class="btn btn-secondary text-sm" [disabled]="loadingLogs()">
<svg class="w-4 h-4" [class.animate-spin]="loadingLogs()" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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"></path>
</svg>
</button>
</div>
</div>
@if (loadingLogs()) {
<div class="text-center py-8">
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
} @else {
<div class="bg-gray-900 rounded-md p-4 overflow-x-auto">
<pre class="text-xs text-gray-100 font-mono">{{ logs() || 'No logs available' }}</pre>
<div class="bg-gray-900 rounded-md p-4 overflow-x-auto max-h-96 overflow-y-auto">
@if (filteredLogs().length === 0) {
<p class="text-sm text-gray-400">No logs available</p>
} @else {
@for (line of filteredLogs(); track $index) {
<div class="text-xs font-mono mb-1" [ngClass]="{
'text-red-400': isLogLevel(line, 'error'),
'text-yellow-400': isLogLevel(line, 'warn'),
'text-blue-300': isLogLevel(line, 'info'),
'text-gray-400': isLogLevel(line, 'debug'),
'text-gray-100': !hasLogLevel(line)
}">{{ line }}</div>
}
}
</div>
}
@if (filteredLogs().length > 0 && filteredLogs().length !== logLines().length) {
<div class="mt-2 text-sm text-gray-500">
Showing {{ filteredLogs().length }} of {{ logLines().length }} lines
</div>
}
</div>
}
}
</div>
`,
})
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<Service | null>(null);
logs = signal('');
logLines = signal<string[]>([]);
filteredLogs = signal<string[]>([]);
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<EnvVar[]>([]);
// Domain validation
availableDomains = signal<Domain[]>([]);
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<string, string> = {};
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);
}
}
}

View File

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

View File

@@ -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: `
<div class="min-h-screen bg-gray-50">
<!-- Navigation -->
@@ -48,11 +49,11 @@ import { AuthService } from '../../core/services/auth.service';
DNS
</a>
<a
routerLink="/ssl"
routerLink="/domains"
routerLinkActive="border-primary-500 text-gray-900"
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
SSL
Domains
</a>
<a
routerLink="/settings"
@@ -77,6 +78,9 @@ import { AuthService } from '../../core/services/auth.service';
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<router-outlet></router-outlet>
</main>
<!-- Toast Notifications -->
<app-toast></app-toast>
</div>
`,
})

View File

@@ -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: `
<div class="flex items-center justify-center" [class]="containerClass">
<div
class="spinner border-t-transparent rounded-full animate-spin"
[ngClass]="{
'w-4 h-4 border-2': size === 'sm',
'w-6 h-6 border-2': size === 'md',
'w-8 h-8 border-3': size === 'lg',
'w-12 h-12 border-4': size === 'xl',
'border-primary-600': color === 'primary',
'border-white': color === 'white',
'border-gray-600': color === 'gray'
}"
></div>
@if (text) {
<span class="ml-3 text-sm text-gray-600">{{ text }}</span>
}
</div>
`,
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;
}

View File

@@ -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: `
<div class="fixed top-4 right-4 z-50 space-y-2">
@for (toast of toastService.toasts(); track toast.id) {
<div
class="toast-item animate-slide-in-right shadow-lg rounded-lg px-4 py-3 flex items-start gap-3 min-w-[320px] max-w-md"
[ngClass]="{
'bg-green-50 border-l-4 border-green-500 text-green-900': toast.type === 'success',
'bg-red-50 border-l-4 border-red-500 text-red-900': toast.type === 'error',
'bg-blue-50 border-l-4 border-blue-500 text-blue-900': toast.type === 'info',
'bg-yellow-50 border-l-4 border-yellow-500 text-yellow-900': toast.type === 'warning'
}"
>
<!-- Icon -->
<div class="flex-shrink-0 mt-0.5">
@if (toast.type === 'success') {
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
}
@if (toast.type === 'error') {
<svg class="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
}
@if (toast.type === 'info') {
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
}
@if (toast.type === 'warning') {
<svg class="w-5 h-5 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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"></path>
</svg>
}
</div>
<!-- Message -->
<div class="flex-1 text-sm font-medium">
{{ toast.message }}
</div>
<!-- Close button -->
<button
type="button"
(click)="toastService.remove(toast.id)"
class="flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
}
</div>
`,
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);
}