feat(web): Add deployments API typings and web UI improvements: services & deployments management with CRUD and actions

This commit is contained in:
2025-09-08 06:46:14 +00:00
parent c142519004
commit e19639c9be
7 changed files with 809 additions and 75 deletions

View File

@@ -22,62 +22,222 @@ export class CloudlyViewDeployments extends DeesElement {
constructor() {
super();
const subecription = appstate.dataState
const subscription = appstate.dataState
.select((stateArg) => stateArg)
.subscribe((dataArg) => {
this.data = dataArg;
});
this.rxSubscriptions.push(subecription);
this.rxSubscriptions.push(subscription);
}
public static styles = [
cssManager.defaultStyles,
shared.viewHostCss,
css`
.status-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 0.85em;
font-weight: 500;
}
.status-running {
background: #4caf50;
color: white;
}
.status-stopped {
background: #f44336;
color: white;
}
.status-paused {
background: #ff9800;
color: white;
}
.status-deploying {
background: #2196f3;
color: white;
}
.health-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.85em;
}
.health-healthy {
background: #e8f5e9;
color: #2e7d32;
}
.health-unhealthy {
background: #ffebee;
color: #c62828;
}
.health-unknown {
background: #f5f5f5;
color: #666;
}
.resource-usage {
display: flex;
gap: 12px;
font-size: 0.9em;
color: #888;
}
.resource-item {
display: flex;
align-items: center;
gap: 4px;
}
`,
];
private getServiceName(serviceId: string): string {
const service = this.data.services?.find(s => s.id === serviceId);
return service?.data?.name || serviceId;
}
private getNodeName(nodeId: string): string {
// This would ideally look up the cluster node name
// For now just return the ID shortened
return nodeId.substring(0, 8);
}
private getStatusBadgeHtml(status: string): any {
const className = `status-badge status-${status}`;
return html`<span class="${className}">${status}</span>`;
}
private getHealthIndicatorHtml(health?: string): any {
if (!health) health = 'unknown';
const className = `health-indicator health-${health}`;
const icon = health === 'healthy' ? '✓' : health === 'unhealthy' ? '✗' : '?';
return html`<span class="${className}">${icon} ${health}</span>`;
}
private getResourceUsageHtml(deployment: plugins.interfaces.data.IDeployment): any {
if (!deployment.resourceUsage) {
return html`<span style="color: #aaa;">N/A</span>`;
}
const { cpuUsagePercent, memoryUsedMB } = deployment.resourceUsage;
return html`
<div class="resource-usage">
<div class="resource-item">
<lucide-icon name="Cpu" size="14"></lucide-icon>
${cpuUsagePercent?.toFixed(1) || 0}%
</div>
<div class="resource-item">
<lucide-icon name="MemoryStick" size="14"></lucide-icon>
${memoryUsedMB || 0} MB
</div>
</div>
`;
}
public render() {
return html`
<cloudly-sectionheading>Deployments</cloudly-sectionheading>
<dees-table
.heading1=${'Deployments'}
.heading2=${'decoded in client'}
.data=${this.data.deployments}
.displayFunction=${(itemArg: plugins.interfaces.data.ICluster) => {
.heading2=${'Service deployments running on cluster nodes'}
.data=${this.data.deployments || []}
.displayFunction=${(itemArg: plugins.interfaces.data.IDeployment) => {
return {
id: itemArg.id,
serverAmount: itemArg.data.servers.length,
Service: this.getServiceName(itemArg.serviceId),
Node: this.getNodeName(itemArg.nodeId),
Status: this.getStatusBadgeHtml(itemArg.status),
Health: this.getHealthIndicatorHtml(itemArg.healthStatus),
'Container ID': itemArg.containerId ?
html`<span style="font-family: monospace; font-size: 0.9em;">${itemArg.containerId.substring(0, 12)}</span>` :
html`<span style="color: #aaa;">N/A</span>`,
Version: itemArg.version || 'latest',
'Resource Usage': this.getResourceUsageHtml(itemArg),
'Last Updated': itemArg.deployedAt ?
new Date(itemArg.deployedAt).toLocaleString() :
'Never',
};
}}
.dataActions=${[
{
name: 'add configBundle',
name: 'Deploy Service',
iconName: 'plus',
type: ['header', 'footer'],
actionFunc: async (dataActionArg) => {
const availableServices = this.data.services || [];
if (availableServices.length === 0) {
plugins.deesCatalog.DeesModal.createAndShow({
heading: 'No Services Available',
content: html`
<div style="text-align: center; padding: 24px;">
<lucide-icon name="AlertCircle" size="48" style="color: #ff9800; margin-bottom: 16px;"></lucide-icon>
<div>Please create a service first before creating deployments.</div>
</div>
`,
menuOptions: [
{
name: 'OK',
action: async (modalArg) => {
await modalArg.destroy();
},
},
],
});
return;
}
const modal = await plugins.deesCatalog.DeesModal.createAndShow({
heading: 'Add ConfigBundle',
heading: 'Deploy Service',
content: html`
<dees-form>
<dees-input-text .key=${'id'} .label=${'ID'} .value=${''}></dees-input-text>
<dees-input-text
.key=${'data.secretGroupIds'}
.label=${'secretGroupIds'}
.value=${''}
></dees-input-text>
<dees-input-text
.key=${'data.includedTags'}
.label=${'includedTags'}
.value=${''}
></dees-input-text>
<dees-input-dropdown
.key=${'serviceId'}
.label=${'Service'}
.options=${availableServices.map(s => ({ key: s.id, value: s.data.name }))}
.required=${true}>
</dees-input-dropdown>
<dees-input-text
.key=${'nodeId'}
.label=${'Target Node ID'}
.required=${true}
.description=${'Enter the cluster node ID where this service should be deployed'}>
</dees-input-text>
<dees-input-text
.key=${'version'}
.label=${'Version'}
.value=${'latest'}
.required=${true}>
</dees-input-text>
<dees-input-dropdown
.key=${'status'}
.label=${'Initial Status'}
.options=${['deploying', 'running']}
.value=${'deploying'}
.required=${true}>
</dees-input-dropdown>
</dees-form>
`,
menuOptions: [
{ name: 'create', action: async (modalArg) => {} },
{
name: 'cancel',
name: 'Deploy',
action: async (modalArg) => {
const form = modalArg.shadowRoot.querySelector('dees-form') as any;
const formData = await form.gatherData();
await appstate.dataState.dispatchAction(appstate.createDeploymentAction, {
deploymentData: {
serviceId: formData.serviceId,
nodeId: formData.nodeId,
status: formData.status,
version: formData.version,
deployedAt: Date.now(),
usedImageId: 'placeholder', // This would come from the service
deploymentLog: [],
},
});
await modalArg.destroy();
},
},
{
name: 'Cancel',
action: async (modalArg) => {
modalArg.destroy();
},
@@ -87,34 +247,96 @@ export class CloudlyViewDeployments extends DeesElement {
},
},
{
name: 'delete',
iconName: 'trash',
name: 'Restart',
iconName: 'refresh-cw',
type: ['contextmenu', 'inRow'],
actionFunc: async (actionDataArg) => {
const deployment = actionDataArg.item as plugins.interfaces.data.IDeployment;
plugins.deesCatalog.DeesModal.createAndShow({
heading: `Delete ConfigBundle ${actionDataArg.item.id}`,
heading: `Restart Deployment`,
content: html`
<div style="text-align:center">
Do you really want to delete the ConfigBundle?
Are you sure you want to restart this deployment?
</div>
<div
style="font-size: 0.8em; color: red; text-align:center; padding: 16px; margin-top: 24px; border: 1px solid #444; font-family: Intel One Mono; font-size: 16px;"
>
${actionDataArg.item.id}
<div style="margin-top: 16px; padding: 16px; background: #333; border-radius: 8px;">
<div style="color: #fff; font-weight: bold;">
${this.getServiceName(deployment.serviceId)}
</div>
<div style="color: #aaa; font-size: 0.9em; margin-top: 4px;">
Node: ${this.getNodeName(deployment.nodeId)}
</div>
</div>
`,
menuOptions: [
{
name: 'cancel',
name: 'Cancel',
action: async (modalArg) => {
await modalArg.destroy();
},
},
{
name: 'delete',
name: 'Restart',
action: async (modalArg) => {
appstate.dataState.dispatchAction(appstate.deleteSecretBundleAction, {
configBundleId: actionDataArg.item.id,
// TODO: Implement restart action
console.log('Restart deployment:', deployment);
await modalArg.destroy();
},
},
],
});
},
},
{
name: 'Stop',
iconName: 'square',
type: ['contextmenu', 'inRow'],
actionFunc: async (actionDataArg) => {
const deployment = actionDataArg.item as plugins.interfaces.data.IDeployment;
await appstate.dataState.dispatchAction(appstate.updateDeploymentAction, {
deploymentId: deployment.id,
deploymentData: {
...deployment,
status: 'stopped',
},
});
},
},
{
name: 'Delete',
iconName: 'trash',
type: ['contextmenu', 'inRow'],
actionFunc: async (actionDataArg) => {
const deployment = actionDataArg.item as plugins.interfaces.data.IDeployment;
plugins.deesCatalog.DeesModal.createAndShow({
heading: `Delete Deployment`,
content: html`
<div style="text-align:center">
Are you sure you want to delete this deployment?
</div>
<div style="margin-top: 16px; padding: 16px; background: #333; border-radius: 8px;">
<div style="color: #fff; font-weight: bold;">
${this.getServiceName(deployment.serviceId)}
</div>
<div style="color: #aaa; font-size: 0.9em; margin-top: 4px;">
Node: ${this.getNodeName(deployment.nodeId)}
</div>
<div style="color: #f44336; margin-top: 8px;">
This action cannot be undone.
</div>
</div>
`,
menuOptions: [
{
name: 'Cancel',
action: async (modalArg) => {
await modalArg.destroy();
},
},
{
name: 'Delete',
action: async (modalArg) => {
await appstate.dataState.dispatchAction(appstate.deleteDeploymentAction, {
deploymentId: deployment.id,
});
await modalArg.destroy();
},
@@ -127,4 +349,4 @@ export class CloudlyViewDeployments extends DeesElement {
></dees-table>
`;
}
}
}

View File

@@ -22,62 +22,187 @@ export class CloudlyViewServices extends DeesElement {
constructor() {
super();
const subecription = appstate.dataState
const subscription = appstate.dataState
.select((stateArg) => stateArg)
.subscribe((dataArg) => {
this.data = dataArg;
});
this.rxSubscriptions.push(subecription);
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;
}
`,
];
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`<span class="${className}">${category}</span>`;
}
private getStrategyBadgeHtml(strategy: string): any {
return html`<span class="strategy-badge">${strategy}</span>`;
}
public render() {
return html`
<cloudly-sectionheading>Services</cloudly-sectionheading>
<dees-table
.heading1=${'Services'}
.heading2=${'decoded in client'}
.data=${this.data.services}
.displayFunction=${(itemArg: plugins.interfaces.data.ICluster) => {
.heading2=${'Service configuration and deployment management'}
.data=${this.data.services || []}
.displayFunction=${(itemArg: plugins.interfaces.data.IService) => {
return {
id: itemArg.id,
serverAmount: itemArg.data.servers.length,
Name: itemArg.data.name,
Description: itemArg.data.description,
Category: this.getCategoryBadgeHtml(itemArg.data.serviceCategory || 'workload'),
'Deployment Strategy': html`
${this.getStrategyBadgeHtml(itemArg.data.deploymentStrategy || 'custom')}
${itemArg.data.maxReplicas ? html`<span style="color: #888; margin-left: 8px;">Max: ${itemArg.data.maxReplicas}</span>` : ''}
${itemArg.data.antiAffinity ? html`<span style="color: #f44336; margin-left: 8px;">⚡ Anti-affinity</span>` : ''}
`,
'Image': `${itemArg.data.imageId}:${itemArg.data.imageVersion}`,
'Scale Factor': itemArg.data.scaleFactor,
'Balancing': itemArg.data.balancingStrategy,
'Deployments': itemArg.data.deploymentIds?.length || 0,
};
}}
.dataActions=${[
{
name: 'add configBundle',
name: 'Add Service',
iconName: 'plus',
type: ['header', 'footer'],
actionFunc: async (dataActionArg) => {
const modal = await plugins.deesCatalog.DeesModal.createAndShow({
heading: 'Add ConfigBundle',
heading: 'Add Service',
content: html`
<dees-form>
<dees-input-text .key=${'id'} .label=${'ID'} .value=${''}></dees-input-text>
<dees-input-text
.key=${'data.secretGroupIds'}
.label=${'secretGroupIds'}
.value=${''}
></dees-input-text>
<dees-input-text
.key=${'data.includedTags'}
.label=${'includedTags'}
.value=${''}
></dees-input-text>
<dees-input-text .key=${'name'} .label=${'Service Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'} .required=${true}></dees-input-text>
<dees-input-dropdown
.key=${'serviceCategory'}
.label=${'Service Category'}
.options=${['base', 'distributed', 'workload']}
.value=${'workload'}
.required=${true}>
</dees-input-dropdown>
<dees-input-dropdown
.key=${'deploymentStrategy'}
.label=${'Deployment Strategy'}
.options=${['all-nodes', 'limited-replicas', 'custom']}
.value=${'custom'}
.required=${true}>
</dees-input-dropdown>
<dees-input-text
.key=${'maxReplicas'}
.label=${'Max Replicas (for distributed services)'}
.value=${'3'}
.type=${'number'}>
</dees-input-text>
<dees-input-checkbox
.key=${'antiAffinity'}
.label=${'Enable Anti-Affinity'}
.value=${false}>
</dees-input-checkbox>
<dees-input-text .key=${'imageId'} .label=${'Image ID'} .required=${true}></dees-input-text>
<dees-input-text .key=${'imageVersion'} .label=${'Image Version'} .value=${'latest'} .required=${true}></dees-input-text>
<dees-input-text
.key=${'scaleFactor'}
.label=${'Scale Factor'}
.value=${'1'}
.type=${'number'}
.required=${true}>
</dees-input-text>
<dees-input-dropdown
.key=${'balancingStrategy'}
.label=${'Balancing Strategy'}
.options=${['round-robin', 'least-connections']}
.value=${'round-robin'}
.required=${true}>
</dees-input-dropdown>
<dees-input-text
.key=${'webPort'}
.label=${'Web Port'}
.value=${'80'}
.type=${'number'}
.required=${true}>
</dees-input-text>
</dees-form>
`,
menuOptions: [
{ name: 'create', action: async (modalArg) => {} },
{
name: 'cancel',
name: 'Create Service',
action: async (modalArg) => {
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,
scaleFactor: parseInt(formData.scaleFactor),
balancingStrategy: formData.balancingStrategy,
ports: {
web: parseInt(formData.webPort),
},
environment: {},
domains: [],
deploymentIds: [],
},
});
await modalArg.destroy();
},
},
{
name: 'Cancel',
action: async (modalArg) => {
modalArg.destroy();
},
@@ -87,34 +212,137 @@ export class CloudlyViewServices extends DeesElement {
},
},
{
name: 'delete',
name: 'Edit',
iconName: 'edit',
type: ['contextmenu', 'inRow'],
actionFunc: async (actionDataArg) => {
const service = actionDataArg.item as plugins.interfaces.data.IService;
const modal = await plugins.deesCatalog.DeesModal.createAndShow({
heading: `Edit Service: ${service.data.name}`,
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Service Name'} .value=${service.data.name} .required=${true}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'} .value=${service.data.description} .required=${true}></dees-input-text>
<dees-input-dropdown
.key=${'serviceCategory'}
.label=${'Service Category'}
.options=${['base', 'distributed', 'workload']}
.value=${service.data.serviceCategory || 'workload'}
.required=${true}>
</dees-input-dropdown>
<dees-input-dropdown
.key=${'deploymentStrategy'}
.label=${'Deployment Strategy'}
.options=${['all-nodes', 'limited-replicas', 'custom']}
.value=${service.data.deploymentStrategy || 'custom'}
.required=${true}>
</dees-input-dropdown>
<dees-input-text
.key=${'maxReplicas'}
.label=${'Max Replicas (for distributed services)'}
.value=${service.data.maxReplicas || ''}
.type=${'number'}>
</dees-input-text>
<dees-input-checkbox
.key=${'antiAffinity'}
.label=${'Enable Anti-Affinity'}
.value=${service.data.antiAffinity || false}>
</dees-input-checkbox>
<dees-input-text .key=${'imageVersion'} .label=${'Image Version'} .value=${service.data.imageVersion} .required=${true}></dees-input-text>
<dees-input-text
.key=${'scaleFactor'}
.label=${'Scale Factor'}
.value=${service.data.scaleFactor}
.type=${'number'}
.required=${true}>
</dees-input-text>
<dees-input-dropdown
.key=${'balancingStrategy'}
.label=${'Balancing Strategy'}
.options=${['round-robin', 'least-connections']}
.value=${service.data.balancingStrategy}
.required=${true}>
</dees-input-dropdown>
</dees-form>
`,
menuOptions: [
{
name: 'Update Service',
action: async (modalArg) => {
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) => {
modalArg.destroy();
},
},
],
});
},
},
{
name: 'Deploy',
iconName: 'rocket',
type: ['contextmenu', 'inRow'],
actionFunc: async (actionDataArg) => {
const service = actionDataArg.item as plugins.interfaces.data.IService;
// TODO: Implement deployment action
console.log('Deploy service:', service);
},
},
{
name: 'Delete',
iconName: 'trash',
type: ['contextmenu', 'inRow'],
actionFunc: async (actionDataArg) => {
const service = actionDataArg.item as plugins.interfaces.data.IService;
plugins.deesCatalog.DeesModal.createAndShow({
heading: `Delete ConfigBundle ${actionDataArg.item.id}`,
heading: `Delete Service: ${service.data.name}`,
content: html`
<div style="text-align:center">
Do you really want to delete the ConfigBundle?
Are you sure you want to delete this service?
</div>
<div
style="font-size: 0.8em; color: red; text-align:center; padding: 16px; margin-top: 24px; border: 1px solid #444; font-family: Intel One Mono; font-size: 16px;"
>
${actionDataArg.item.id}
<div style="margin-top: 16px; padding: 16px; background: #333; border-radius: 8px;">
<div style="color: #fff; font-weight: bold;">${service.data.name}</div>
<div style="color: #aaa; font-size: 0.9em; margin-top: 4px;">${service.data.description}</div>
<div style="color: #f44336; margin-top: 8px;">
This will also delete ${service.data.deploymentIds?.length || 0} deployment(s)
</div>
</div>
`,
menuOptions: [
{
name: 'cancel',
name: 'Cancel',
action: async (modalArg) => {
await modalArg.destroy();
},
},
{
name: 'delete',
name: 'Delete',
action: async (modalArg) => {
appstate.dataState.dispatchAction(appstate.deleteSecretBundleAction, {
configBundleId: actionDataArg.item.id,
await appstate.dataState.dispatchAction(appstate.deleteServiceAction, {
serviceId: service.id,
});
await modalArg.destroy();
},
@@ -127,4 +355,4 @@ export class CloudlyViewServices extends DeesElement {
></dees-table>
`;
}
}
}