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:
@@ -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"],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
237
ts/classes/registry.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
11
ts/types.ts
11
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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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`);
|
||||
|
||||
53
ui/src/app/core/services/toast.service.ts
Normal file
53
ui/src/app/core/services/toast.service.ts
Normal 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([]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
356
ui/src/app/features/domains/domain-detail.component.ts
Normal file
356
ui/src/app/features/domains/domain-detail.component.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 →
|
||||
</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({
|
||||
|
||||
@@ -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 →
|
||||
</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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
})
|
||||
|
||||
48
ui/src/app/shared/components/loading-spinner.component.ts
Normal file
48
ui/src/app/shared/components/loading-spinner.component.ts
Normal 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;
|
||||
}
|
||||
91
ui/src/app/shared/components/toast.component.ts
Normal file
91
ui/src/app/shared/components/toast.component.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user