import * as plugins from '../../../plugins.js'; import * as shared from '../../shared/index.js'; import { DeploymentExecutionEnvironment } from '../../../environments/deployment-environment.js'; import { DeesElement, customElement, html, state, css, cssManager, type TemplateResult, } from '@design.estate/dees-element'; import * as appstate from '../../../appstate.js'; @customElement('cloudly-view-services') export class CloudlyViewServices extends DeesElement { @state() private accessor data: appstate.IDataState = {} as any; @state() private accessor currentView: 'list' | 'detail' | 'workspace' = 'list'; @state() private accessor selectedService: plugins.interfaces.data.IService | null = null; @state() private accessor serviceDeployments: plugins.interfaces.data.IDeployment[] = []; @state() private accessor deploymentsLoading = false; @state() private accessor upgradeInfo: any = null; @state() private accessor workspaceEnvironment: DeploymentExecutionEnvironment | null = null; @state() private accessor workspaceDeployment: any = null; constructor() { super(); const subscription = appstate.dataState .select((stateArg) => stateArg) .subscribe((dataArg) => { this.data = dataArg; }); this.rxSubscriptions.push(subscription); } public static styles = [ cssManager.defaultStyles, shared.viewHostCss, css` .category-badge { padding: 2px 8px; border-radius: 4px; font-size: 0.9em; font-weight: 500; } .category-base { background: #2196f3; color: white; } .category-distributed { background: #9c27b0; color: white; } .category-workload { background: #4caf50; color: white; } .strategy-badge { padding: 2px 8px; border-radius: 4px; font-size: 0.85em; background: #444; color: #ccc; margin-left: 4px; } .link-button { border: none; background: transparent; color: var(--ci-color-primary, #60a5fa); cursor: pointer; padding: 0; font: inherit; } .detail-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; } .detail-title { margin: 0; font-size: 26px; font-weight: 700; color: var(--ci-shade-7, #e4e4e7); } .detail-subtitle { margin-top: 6px; color: var(--ci-shade-4, #71717a); font-size: 14px; } .back-button, .primary-button, .danger-button { border: 1px solid var(--ci-shade-2, #27272a); border-radius: 7px; padding: 9px 13px; font-size: 13px; cursor: pointer; background: var(--ci-shade-1, #09090b); color: var(--ci-shade-7, #e4e4e7); } .primary-button { background: var(--ci-color-primary, #2563eb); border-color: var(--ci-color-primary, #2563eb); color: white; } .danger-button { color: #ef4444; border-color: rgba(239, 68, 68, 0.35); } .summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 18px; } .summary-card, .detail-card, .update-card { background: var(--ci-shade-1, #09090b); border: 1px solid var(--ci-shade-2, #27272a); border-radius: 9px; padding: 16px; } .summary-label { font-size: 12px; color: var(--ci-shade-4, #71717a); margin-bottom: 6px; } .summary-value { font-size: 20px; font-weight: 700; color: var(--ci-shade-7, #e4e4e7); overflow-wrap: anywhere; } .section-title { font-size: 14px; font-weight: 700; color: var(--ci-shade-7, #e4e4e7); margin-bottom: 10px; } .details-grid { display: grid; grid-template-columns: 1.2fr 0.8fr; gap: 14px; margin-top: 14px; } .kv-list { display: grid; gap: 8px; } .kv-row { display: grid; grid-template-columns: 150px 1fr; gap: 10px; font-size: 13px; } .kv-key { color: var(--ci-shade-4, #71717a); } .kv-value { color: var(--ci-shade-7, #e4e4e7); overflow-wrap: anywhere; } .status-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; } .status-running { background: rgba(34, 197, 94, 0.16); color: #22c55e; } .status-starting, .status-scheduled { background: rgba(59, 130, 246, 0.16); color: #60a5fa; } .status-stopped { background: rgba(161, 161, 170, 0.16); color: #a1a1aa; } .status-failed { background: rgba(239, 68, 68, 0.16); color: #ef4444; } .update-card { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; border-color: rgba(59, 130, 246, 0.35); background: linear-gradient(135deg, rgba(59, 130, 246, 0.10), rgba(139, 92, 246, 0.10)); } .workspace-shell { display: grid; grid-template-rows: auto 1fr; height: calc(100vh - 120px); min-height: 560px; } .workspace-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } dees-workspace { min-height: 0; } @media (max-width: 900px) { .summary-grid, .details-grid { grid-template-columns: 1fr; } .detail-header { flex-direction: column; } } `, ]; private getCategoryIcon(category: string): string { switch (category) { case 'base': return 'lucide:ServerCog'; case 'distributed': return 'lucide:Network'; case 'workload': return 'lucide:Container'; default: return 'lucide:Box'; } } private getCategoryBadgeHtml(category: string): any { const className = `category-badge category-${category}`; return html`${category}`; } private getStrategyBadgeHtml(strategy: string): any { return html`${strategy}`; } public render(): TemplateResult { if (this.currentView === 'workspace') { return this.renderWorkspaceView(); } if (this.currentView === 'detail') { return this.renderDetailView(); } return this.renderListView(); } private renderListView(): TemplateResult { return html` Services { return { Name: html``, Description: itemArg.data.description, Category: this.getCategoryBadgeHtml(itemArg.data.serviceCategory || 'workload'), 'Deployment Strategy': html` ${this.getStrategyBadgeHtml(itemArg.data.deploymentStrategy || 'custom')} ${itemArg.data.maxReplicas ? html`Max: ${itemArg.data.maxReplicas}` : ''} ${itemArg.data.antiAffinity ? html`⚡ Anti-affinity` : ''} `, 'Image': `${itemArg.data.imageId}:${itemArg.data.imageVersion}`, 'Scale Factor': itemArg.data.scaleFactor, 'Balancing': itemArg.data.balancingStrategy, 'Deployments': itemArg.data.deploymentIds?.length || 0, }; }} .dataActions=${[ { name: 'Details', iconName: 'eye', type: ['contextmenu', 'inRow'], actionFunc: async (actionDataArg: any) => { await this.openServiceDetail(actionDataArg.item as plugins.interfaces.data.IService); }, }, { name: 'Add Service', iconName: 'plus', type: ['header', 'footer'], actionFunc: async () => { const modal = await plugins.deesCatalog.DeesModal.createAndShow({ heading: 'Add Service', content: html` `, menuOptions: [ { name: 'Create Service', action: async (modalArg: any) => { const form = modalArg.shadowRoot.querySelector('dees-form') as any; const formData = await form.gatherData(); await appstate.dataState.dispatchAction(appstate.createServiceAction, { serviceData: { name: formData.name, description: formData.description, serviceCategory: formData.serviceCategory, deploymentStrategy: formData.deploymentStrategy, maxReplicas: formData.maxReplicas ? parseInt(formData.maxReplicas) : undefined, antiAffinity: formData.antiAffinity, imageId: formData.imageId, imageVersion: formData.imageVersion, secretBundleId: '', scaleFactor: parseInt(formData.scaleFactor), balancingStrategy: formData.balancingStrategy, ports: { web: parseInt(formData.webPort) }, environment: {}, domains: [], deploymentIds: [], }, }); await modalArg.destroy(); }}, { name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() }, ], }); }, }, { name: 'Edit', iconName: 'edit', type: ['contextmenu', 'inRow'], actionFunc: async (actionDataArg: any) => { const service = actionDataArg.item as plugins.interfaces.data.IService; const modal = await plugins.deesCatalog.DeesModal.createAndShow({ heading: `Edit Service: ${service.data.name}`, content: html` `, menuOptions: [ { name: 'Update Service', action: async (modalArg: any) => { const form = modalArg.shadowRoot.querySelector('dees-form') as any; const formData = await form.gatherData(); await appstate.dataState.dispatchAction(appstate.updateServiceAction, { serviceId: service.id, serviceData: { ...service.data, name: formData.name, description: formData.description, serviceCategory: formData.serviceCategory, deploymentStrategy: formData.deploymentStrategy, maxReplicas: formData.maxReplicas ? parseInt(formData.maxReplicas) : undefined, antiAffinity: formData.antiAffinity, imageVersion: formData.imageVersion, scaleFactor: parseInt(formData.scaleFactor), balancingStrategy: formData.balancingStrategy, }, }); await modalArg.destroy(); }}, { name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() }, ], }); }, }, { name: 'Deploy', iconName: 'rocket', type: ['contextmenu', 'inRow'], actionFunc: async (actionDataArg: any) => { const service = actionDataArg.item as plugins.interfaces.data.IService; console.log('Deploy service:', service); }, }, { name: 'Delete', iconName: 'trash', type: ['contextmenu', 'inRow'], actionFunc: async (actionDataArg: any) => { const service = actionDataArg.item as plugins.interfaces.data.IService; plugins.deesCatalog.DeesModal.createAndShow({ heading: `Delete Service: ${service.data.name}`, content: html`
Are you sure you want to delete this service?
${service.data.name}
${service.data.description}
This will also delete ${service.data.deploymentIds?.length || 0} deployment(s)
`, menuOptions: [ { name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() }, { name: 'Delete', action: async (modalArg: any) => { await appstate.dataState.dispatchAction(appstate.deleteServiceAction, { serviceId: service.id }); await modalArg.destroy(); } }, ], }); }, }, ] as plugins.deesCatalog.ITableAction[]} >
`; } private renderDetailView(): TemplateResult { const service = this.selectedService; if (!service) { return html` Service Details `; } const runningDeployments = this.serviceDeployments.filter((deploymentArg) => deploymentArg.status === 'running').length; const desiredReplicas = service.data.maxReplicas || service.data.scaleFactor || 1; const domains = service.data.domains || []; const volumes = service.data.volumes || []; const serviceData = service.data as plugins.interfaces.data.IService['data'] & { appTemplateId?: string; appTemplateVersion?: string; }; return html` Service Details

${service.data.name}

${service.data.description || 'No description configured'}
${this.upgradeInfo ? html`
App catalog update available
${this.upgradeInfo.appTemplateId}: ${this.upgradeInfo.currentVersion} -> ${this.upgradeInfo.latestVersion}
` : ''}
Running Deployments
${runningDeployments}/${desiredReplicas}
Image
${service.data.imageId}:${service.data.imageVersion}
Strategy
${service.data.deploymentStrategy}
Category
${service.data.serviceCategory}
Deployments
Container-level runtime actions happen here.
${this.deploymentsLoading ? html`
Loading deployments...
` : html` ({ Status: this.renderStatusBadge(deploymentArg.status), Node: deploymentArg.nodeName || deploymentArg.nodeId || '-', Slot: deploymentArg.slot || '-', Version: deploymentArg.version || service.data.imageVersion, Container: deploymentArg.containerId ? deploymentArg.containerId.slice(0, 12) : '-', CPU: deploymentArg.resourceUsage ? `${deploymentArg.resourceUsage.cpuUsagePercent.toFixed(1)}%` : '-', Memory: deploymentArg.resourceUsage ? `${deploymentArg.resourceUsage.memoryUsedMB} MB` : '-', Updated: deploymentArg.updatedAt ? new Date(deploymentArg.updatedAt).toLocaleString() : '-', })} .dataActions=${[ { name: 'Open IDE', iconName: 'terminal', type: ['contextmenu', 'inRow'], actionFunc: async (actionDataArg: any) => { await this.openDeploymentWorkspace(actionDataArg.item); }, }, { name: 'Restart', iconName: 'refresh-cw', type: ['contextmenu', 'inRow'], actionFunc: async (actionDataArg: any) => { await this.restartDeployment(actionDataArg.item); }, }, { name: 'Kill Container', iconName: 'skull', type: ['contextmenu', 'inRow'], actionFunc: async (actionDataArg: any) => { await this.confirmKillDeployment(actionDataArg.item); }, }, ] as plugins.deesCatalog.ITableAction[]} > `}
Service Configuration
Service ID${service.id}
Image ID${service.data.imageId}
Image Version${service.data.imageVersion}
Web Port${service.data.ports?.web || '-'}
Deploy on Push${service.data.deployOnPush === false ? 'disabled' : 'enabled'}
App Template${serviceData.appTemplateId ? `${serviceData.appTemplateId}@${serviceData.appTemplateVersion}` : '-'}
Registry Target${service.data.registryTarget?.imageUrl || '-'}
Routes, Volumes, Secrets
Domains${domains.length ? domains.map((domainArg) => domainArg.name).join(', ') : '-'}
Volumes${volumes.length ? volumes.map((volumeArg) => volumeArg.mountPath).join(', ') : '-'}
Secret Bundle${service.data.secretBundleId || '-'}
Extra Bundles${service.data.additionalSecretBundleIds?.length || 0}
Env Keys${Object.keys(service.data.environment || {}).join(', ') || '-'}
`; } private renderWorkspaceView(): TemplateResult { return html` Deployment IDE
${this.selectedService?.data.name || 'Deployment'} workspace
${this.workspaceDeployment?.containerId || this.workspaceDeployment?.id || ''}
${this.workspaceEnvironment ? html`` : html`
Workspace is not available.
`}
`; } private renderStatusBadge(statusArg: string): TemplateResult { return html`${statusArg || 'scheduled'}`; } private async openServiceDetail(serviceArg: plugins.interfaces.data.IService) { this.selectedService = serviceArg; this.serviceDeployments = []; this.upgradeInfo = null; this.currentView = 'detail'; await Promise.all([ this.loadDeploymentsForService(serviceArg), this.loadUpgradeInfo(serviceArg), ]); } private async loadDeploymentsForService(serviceArg: plugins.interfaces.data.IService) { this.deploymentsLoading = true; try { const response = await this.fireTypedRequest('getDeploymentsByService', { serviceId: serviceArg.id, }) as { deployments: plugins.interfaces.data.IDeployment[] }; this.serviceDeployments = response.deployments || []; } catch (error) { console.error('Failed to load service deployments:', error); this.serviceDeployments = []; } finally { this.deploymentsLoading = false; } } private async loadUpgradeInfo(serviceArg: plugins.interfaces.data.IService) { try { const response = await this.fireTypedRequest('getUpgradeableServices', {}) as { services: any[] }; this.upgradeInfo = response.services?.find((upgradeArg) => upgradeArg.serviceName === serviceArg.data.name) || null; } catch { this.upgradeInfo = null; } } private async restartDeployment(deploymentArg: plugins.interfaces.data.IDeployment) { await this.fireTypedRequest('restartDeployment', { deploymentId: deploymentArg.id }); if (this.selectedService) { await this.loadDeploymentsForService(this.selectedService); } } private async confirmKillDeployment(deploymentArg: plugins.interfaces.data.IDeployment) { await plugins.deesCatalog.DeesModal.createAndShow({ heading: 'Kill Deployment Container', content: html`
This kills the running container for deployment ${deploymentArg.id}. Docker Swarm may create a replacement task if the service still desires a replica.
`, menuOptions: [ { name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() }, { name: 'Kill Container', action: async (modalArg: any) => { await this.fireTypedRequest('killDeployment', { deploymentId: deploymentArg.id }); await modalArg.destroy(); if (this.selectedService) { await this.loadDeploymentsForService(this.selectedService); } } }, ], }); } private async openDeploymentWorkspace(deploymentArg: any) { const identity = appstate.loginStatePart.getState()?.identity; if (!identity) return; const environment = new DeploymentExecutionEnvironment(deploymentArg.id, identity); await environment.init(); this.workspaceDeployment = deploymentArg; this.workspaceEnvironment = environment; this.currentView = 'workspace'; } private async fireTypedRequest(methodArg: string, dataArg: Record) { const identity = appstate.loginStatePart.getState()?.identity; if (!identity) { throw new Error('Not logged in'); } const typedRequest = new plugins.typedrequest.TypedRequest( '/typedrequest', methodArg, ); return await typedRequest.fire({ identity, ...dataArg, }); } } declare global { interface HTMLElementTagNameMap { 'cloudly-view-services': CloudlyViewServices; } }