feat(domains): enhance domain management with activation states and sync options
This commit is contained in:
		| @@ -69,12 +69,15 @@ export class DnsManager { | ||||
|             this.cloudlyRef.authManager.validIdentityGuard, | ||||
|           ]); | ||||
|  | ||||
|           // Validate domain exists if domainId is provided | ||||
|           // Validate domain exists and is activated if domainId is provided | ||||
|           if (reqArg.dnsEntryData.domainId) { | ||||
|             const domain = await this.cloudlyRef.domainManager.CDomain.getDomainById(reqArg.dnsEntryData.domainId); | ||||
|             if (!domain) { | ||||
|               throw new Error(`Domain with id ${reqArg.dnsEntryData.domainId} not found`); | ||||
|             } | ||||
|             if ((domain.data as any).activationState !== 'activated') { | ||||
|               throw new Error(`Domain ${domain.data.name} is not activated; DNS changes are not allowed.`); | ||||
|             } | ||||
|             // Set the zone from the domain name | ||||
|             reqArg.dnsEntryData.zone = domain.data.name; | ||||
|           } | ||||
| @@ -97,12 +100,15 @@ export class DnsManager { | ||||
|             this.cloudlyRef.authManager.validIdentityGuard, | ||||
|           ]); | ||||
|  | ||||
|           // Validate domain exists if domainId is provided | ||||
|           // Validate domain exists and is activated if domainId is provided | ||||
|           if (reqArg.dnsEntryData.domainId) { | ||||
|             const domain = await this.cloudlyRef.domainManager.CDomain.getDomainById(reqArg.dnsEntryData.domainId); | ||||
|             if (!domain) { | ||||
|               throw new Error(`Domain with id ${reqArg.dnsEntryData.domainId} not found`); | ||||
|             } | ||||
|             if ((domain.data as any).activationState !== 'activated') { | ||||
|               throw new Error(`Domain ${domain.data.name} is not activated; DNS changes are not allowed.`); | ||||
|             } | ||||
|             // Set the zone from the domain name | ||||
|             reqArg.dnsEntryData.zone = domain.data.name; | ||||
|           } | ||||
|   | ||||
| @@ -36,6 +36,9 @@ export class Domain extends plugins.smartdata.SmartDataDbDoc< | ||||
|       verificationStatus: domainDataArg.verificationStatus || 'pending', | ||||
|       nameservers: domainDataArg.nameservers || [], | ||||
|       autoRenew: domainDataArg.autoRenew !== false, | ||||
|       activationState: domainDataArg.activationState || 'available', | ||||
|       syncSource: domainDataArg.syncSource ?? null, | ||||
|       lastSyncAt: domainDataArg.lastSyncAt, | ||||
|       createdAt: Date.now(), | ||||
|       updatedAt: Date.now(), | ||||
|     }; | ||||
| @@ -55,6 +58,7 @@ export class Domain extends plugins.smartdata.SmartDataDbDoc< | ||||
|     } | ||||
|     Object.assign(domain.data, domainDataArg, { | ||||
|       updatedAt: Date.now(), | ||||
|       activationState: domain.data.activationState || 'available', | ||||
|     }); | ||||
|     await domain.save(); | ||||
|     return domain; | ||||
|   | ||||
| @@ -7,6 +7,86 @@ import { logger } from '../logger.js'; | ||||
|  */ | ||||
| export function createPredefinedTasks(taskManager: CloudlyTaskManager) { | ||||
|    | ||||
|   // Cloudflare Domain Sync Task | ||||
|   const cfDomainSync = new plugins.taskbuffer.Task({ | ||||
|     name: 'cloudflare-domain-sync', | ||||
|     taskFunction: async () => { | ||||
|       const execution = taskManager.getCurrentExecution('cloudflare-domain-sync'); | ||||
|       try { | ||||
|         await execution?.addLog('Starting Cloudflare domain sync…', 'info'); | ||||
|         const cf = taskManager.cloudlyRef.cloudflareConnector?.cloudflare; | ||||
|         if (!cf) { | ||||
|           await execution?.addLog('Cloudflare not configured; skipping sync.', 'warning'); | ||||
|           return { created: 0, updated: 0, totalZones: 0 }; | ||||
|         } | ||||
|  | ||||
|         const zones = await cf.convenience.listZones(); | ||||
|         await execution?.setMetric('totalZones', zones.length); | ||||
|         await execution?.addLog(`Fetched ${zones.length} zones from Cloudflare`, 'info'); | ||||
|  | ||||
|         let created = 0; | ||||
|         let updated = 0; | ||||
|         const now = Date.now(); | ||||
|  | ||||
|         for (const zone of zones) { | ||||
|           // zone fields from Cloudflare typings | ||||
|           const zoneName = (zone as any).name as string; | ||||
|           const zoneId = (zone as any).id as string; | ||||
|           const zoneStatus = ((zone as any).status || 'active') as 'active'|'pending'|'suspended'|'transferred'|'expired'; | ||||
|           const nameServers: string[] = (zone as any).name_servers || []; | ||||
|  | ||||
|           const existing = await taskManager.cloudlyRef.domainManager.CDomain.getDomainByName(zoneName); | ||||
|           if (existing) { | ||||
|             if (execution && (taskManager.isCancellationRequested(execution.id) || existing.data == null)) { | ||||
|               await execution?.addLog('Cancellation requested. Stopping CF sync…', 'warning'); | ||||
|               break; | ||||
|             } | ||||
|             await execution?.addLog(`Updating domain: ${zoneName}`, 'info'); | ||||
|             await taskManager.cloudlyRef.domainManager.CDomain.updateDomain(existing.id, { | ||||
|               status: zoneStatus as any, | ||||
|               nameservers: nameServers, | ||||
|               cloudflareZoneId: zoneId, | ||||
|               syncSource: 'cloudflare', | ||||
|               lastSyncAt: now, | ||||
|               activationState: existing.data.activationState || 'available', | ||||
|             }); | ||||
|             updated++; | ||||
|           } else { | ||||
|             await execution?.addLog(`Creating domain: ${zoneName}`, 'info'); | ||||
|             await taskManager.cloudlyRef.domainManager.CDomain.createDomain({ | ||||
|               name: zoneName, | ||||
|               description: `Synced from Cloudflare zone ${zoneId}`, | ||||
|               status: zoneStatus as any, | ||||
|               verificationStatus: 'pending', | ||||
|               nameservers: nameServers, | ||||
|               autoRenew: true, | ||||
|               cloudflareZoneId: zoneId, | ||||
|               activationState: 'available', | ||||
|               syncSource: 'cloudflare', | ||||
|               lastSyncAt: now, | ||||
|             } as any); | ||||
|             created++; | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         await execution?.setMetric('created', created); | ||||
|         await execution?.setMetric('updated', updated); | ||||
|         await execution?.addLog(`Cloudflare sync done: ${created} created, ${updated} updated`, 'success'); | ||||
|         return { created, updated, totalZones: zones.length }; | ||||
|       } catch (error) { | ||||
|         await execution?.addLog(`Cloudflare sync error: ${error.message}`, 'error'); | ||||
|         throw error; | ||||
|       } | ||||
|     }, | ||||
|   }); | ||||
|  | ||||
|   taskManager.registerTask('cloudflare-domain-sync', cfDomainSync, { | ||||
|     description: 'Import and update domains from Cloudflare zones', | ||||
|     category: 'system', | ||||
|     schedule: '0 3 * * *', // Daily at 3 AM | ||||
|     enabled: true, | ||||
|   }); | ||||
|  | ||||
|   // DNS Sync Task | ||||
|   const dnsSync = new plugins.taskbuffer.Task({ | ||||
|     name: 'dns-sync', | ||||
| @@ -79,8 +159,10 @@ export function createPredefinedTasks(taskManager: CloudlyTaskManager) { | ||||
|           return; | ||||
|         } | ||||
|          | ||||
|         // Get all domains | ||||
|         const domains = await taskManager.cloudlyRef.domainManager.CDomain.getInstances({}); | ||||
|         // Get all domains (only activated ones are considered for renewal) | ||||
|         const domains = await taskManager.cloudlyRef.domainManager.CDomain.getInstances({ | ||||
|           'data.activationState': 'activated', | ||||
|         } as any); | ||||
|         await execution?.setMetric('totalDomains', domains.length); | ||||
|          | ||||
|         let renewedCount = 0; | ||||
|   | ||||
| @@ -72,6 +72,14 @@ export interface IDomain { | ||||
|      */ | ||||
|     sslStatus?: 'active' | 'pending' | 'expired' | 'none'; | ||||
|  | ||||
|     /** | ||||
|      * Cloudly activation state controls whether we actively manage DNS/certificates | ||||
|      * - available: discovered/imported, not actively managed | ||||
|      * - activated: actively managed (DNS edits allowed, certs considered) | ||||
|      * - ignored: explicitly ignored from management/automation | ||||
|      */ | ||||
|     activationState?: 'available' | 'activated' | 'ignored'; | ||||
|      | ||||
|     /** | ||||
|      * Last verification attempt timestamp | ||||
|      */ | ||||
| @@ -92,6 +100,12 @@ export interface IDomain { | ||||
|      */ | ||||
|     cloudflareZoneId?: string; | ||||
|  | ||||
|     /** | ||||
|      * Sync metadata | ||||
|      */ | ||||
|     syncSource?: 'cloudflare' | 'manual' | null; | ||||
|     lastSyncAt?: number; | ||||
|      | ||||
|     /** | ||||
|      * Whether domain is managed externally | ||||
|      */ | ||||
|   | ||||
| @@ -93,6 +93,12 @@ export class CloudlyViewDns extends DeesElement { | ||||
|                   { name: 'Create DNS Entry', action: async (modalArg: any) => { | ||||
|                       const form = modalArg.shadowRoot.querySelector('dees-form') as any; | ||||
|                       const formData = await form.gatherData(); | ||||
|                       // Guard: only allow on activated domains | ||||
|                       const domain = (this.data.domains || []).find((d: any) => d.id === formData.domainId); | ||||
|                       if (!domain || (domain.data as any).activationState !== 'activated') { | ||||
|                         plugins.deesCatalog.DeesToast.createAndShow({ message: 'Selected domain is not activated. Activate it first.', type: 'error' }); | ||||
|                         return; | ||||
|                       } | ||||
|                       await appstate.dataState.dispatchAction(appstate.createDnsEntryAction, { dnsEntryData: { type: formData.type, domainId: formData.domainId, zone: '', name: formData.name || '@', value: formData.value, ttl: parseInt(formData.ttl) || 3600, priority: formData.priority ? parseInt(formData.priority) : undefined, weight: formData.weight ? parseInt(formData.weight) : undefined, port: formData.port ? parseInt(formData.port) : undefined, active: formData.active, description: formData.description || undefined, }, }); | ||||
|                       await modalArg.destroy(); | ||||
|                     } }, | ||||
| @@ -123,6 +129,13 @@ export class CloudlyViewDns extends DeesElement { | ||||
|                   { name: 'Update DNS Entry', action: async (modalArg: any) => { | ||||
|                       const form = modalArg.shadowRoot.querySelector('dees-form') as any; | ||||
|                       const formData = await form.gatherData(); | ||||
|                       if (formData.domainId) { | ||||
|                         const domain = (this.data.domains || []).find((d: any) => d.id === formData.domainId); | ||||
|                         if (!domain || (domain.data as any).activationState !== 'activated') { | ||||
|                           plugins.deesCatalog.DeesToast.createAndShow({ message: 'Selected domain is not activated. Activate it first.', type: 'error' }); | ||||
|                           return; | ||||
|                         } | ||||
|                       } | ||||
|                       await appstate.dataState.dispatchAction(appstate.updateDnsEntryAction, { dnsEntryId: dnsEntry.id, dnsEntryData: { ...dnsEntry.data, type: formData.type, domainId: formData.domainId, zone: '', name: formData.name || '@', value: formData.value, ttl: parseInt(formData.ttl) || 3600, priority: formData.priority ? parseInt(formData.priority) : undefined, weight: formData.weight ? parseInt(formData.weight) : undefined, port: formData.port ? parseInt(formData.port) : undefined, active: formData.active, description: formData.description || undefined, }, }); | ||||
|                       await modalArg.destroy(); | ||||
|                     } }, | ||||
| @@ -140,4 +153,3 @@ export class CloudlyViewDns extends DeesElement { | ||||
| } | ||||
|  | ||||
| declare global { interface HTMLElementTagNameMap { 'cloudly-view-dns': CloudlyViewDns; } } | ||||
|  | ||||
|   | ||||
| @@ -37,6 +37,10 @@ export class CloudlyViewDomains extends DeesElement { | ||||
|       .ssl-expired { color: #f44336; } | ||||
|       .ssl-none { color: #9E9E9E; } | ||||
|       .nameserver-list { font-size: 0.85em; color: #666; } | ||||
|       .activation-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.85em; font-weight: 500; } | ||||
|       .activation-available { background: #2b2b2b; color: #bbb; border: 1px solid #444; } | ||||
|       .activation-activated { background: #4CAF50; color: #fff; } | ||||
|       .activation-ignored { background: #9E9E9E; color: #fff; } | ||||
|       .expiry-warning { color: #FF9800; font-weight: 500; } | ||||
|       .expiry-critical { color: #f44336; font-weight: bold; } | ||||
|     `, | ||||
| @@ -45,6 +49,7 @@ export class CloudlyViewDomains extends DeesElement { | ||||
|   private getStatusBadge(status: string) { return html`<span class="status-badge status-${status}">${status.toUpperCase()}</span>`; } | ||||
|   private getVerificationBadge(status: string) { const displayText = status === 'not_required' ? 'Not Required' : status.replace('_', ' ').toUpperCase(); return html`<span class="verification-badge verification-${status}">${displayText}</span>`; } | ||||
|   private getSslBadge(sslStatus?: string) { if (!sslStatus) return html`<span class="ssl-badge ssl-none">—</span>`; const icon = sslStatus === 'active' ? '🔒' : sslStatus === 'expired' ? '⚠️' : '🔓'; return html`<span class="ssl-badge ssl-${sslStatus}">${icon} ${sslStatus.toUpperCase()}</span>`; } | ||||
|   private getActivationBadge(state?: 'available'|'activated'|'ignored') { const s = state || 'available'; return html`<span class="activation-badge activation-${s}">${s.toUpperCase()}</span>`; } | ||||
|   private formatDate(timestamp?: number) { if (!timestamp) return '—'; const date = new Date(timestamp); return date.toLocaleDateString(); } | ||||
|   private getDaysUntilExpiry(expiresAt?: number) { if (!expiresAt) return null; const days = Math.floor((expiresAt - Date.now()) / (1000 * 60 * 60 * 24)); return days; } | ||||
|   private getExpiryDisplay(expiresAt?: number) { const days = this.getDaysUntilExpiry(expiresAt); if (days === null) return '—'; if (days < 0) { return html`<span class="expiry-critical">Expired ${Math.abs(days)} days ago</span>`; } else if (days <= 30) { return html`<span class="expiry-warning">Expires in ${days} days</span>`; } else { return `${days} days`; } } | ||||
| @@ -63,6 +68,7 @@ export class CloudlyViewDomains extends DeesElement { | ||||
|             Status: this.getStatusBadge(itemArg.data.status), | ||||
|             Verification: this.getVerificationBadge(itemArg.data.verificationStatus), | ||||
|             SSL: this.getSslBadge(itemArg.data.sslStatus), | ||||
|             Activation: this.getActivationBadge((itemArg.data as any).activationState), | ||||
|             'DNS Records': dnsCount, | ||||
|             Registrar: itemArg.data.registrar?.name || '—', | ||||
|             Expires: this.getExpiryDisplay(itemArg.data.expiresAt), | ||||
| @@ -71,6 +77,7 @@ export class CloudlyViewDomains extends DeesElement { | ||||
|           }; | ||||
|         }} | ||||
|         .dataActions=${[ | ||||
|           { name: 'Sync from Cloudflare', iconName: 'cloud', type: ['header'], actionFunc: async () => { await appstate.dataState.dispatchAction(appstate.taskActions.triggerTask, { taskName: 'cloudflare-domain-sync' } as any); await appstate.dataState.dispatchAction(appstate.getAllDataAction, null); plugins.deesCatalog.DeesToast.createAndShow({ message: 'Triggered Cloudflare sync', type: 'success' }); } }, | ||||
|           { name: 'Add Domain', iconName: 'plus', type: ['header', 'footer'], actionFunc: async () => { | ||||
|               const modal = await plugins.deesCatalog.DeesModal.createAndShow({ | ||||
|                 heading: 'Add Domain', | ||||
| @@ -164,6 +171,9 @@ export class CloudlyViewDomains extends DeesElement { | ||||
|                 menuOptions: [ { name: 'Cancel', action: async (modalArg: any) => { await modalArg.destroy(); } }, { name: 'Delete', action: async (modalArg: any) => { await appstate.dataState.dispatchAction(appstate.deleteDomainAction, { domainId: domain.id, }); await modalArg.destroy(); } }, ], | ||||
|               }); | ||||
|             } }, | ||||
|           { name: 'Activate', iconName: 'check', type: ['contextmenu', 'inRow'], actionFunc: async (actionDataArg: any) => { const domain = actionDataArg.item as plugins.interfaces.data.IDomain; await appstate.dataState.dispatchAction(appstate.updateDomainAction, { domainId: domain.id, domainData: { ...(domain.data as any), activationState: 'activated' } as any }); } }, | ||||
|           { name: 'Deactivate', iconName: 'slash', type: ['contextmenu', 'inRow'], actionFunc: async (actionDataArg: any) => { const domain = actionDataArg.item as plugins.interfaces.data.IDomain; await appstate.dataState.dispatchAction(appstate.updateDomainAction, { domainId: domain.id, domainData: { ...(domain.data as any), activationState: 'available' } as any }); } }, | ||||
|           { name: 'Ignore', iconName: 'ban', type: ['contextmenu', 'inRow'], actionFunc: async (actionDataArg: any) => { const domain = actionDataArg.item as plugins.interfaces.data.IDomain; await appstate.dataState.dispatchAction(appstate.updateDomainAction, { domainId: domain.id, domainData: { ...(domain.data as any), activationState: 'ignored' } as any }); } }, | ||||
|         ] as plugins.deesCatalog.ITableAction[]} | ||||
|       ></dees-table> | ||||
|     `; | ||||
| @@ -173,4 +183,3 @@ export class CloudlyViewDomains extends DeesElement { | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { 'cloudly-view-domains': CloudlyViewDomains; } | ||||
| } | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user