588 lines
32 KiB
TypeScript
588 lines
32 KiB
TypeScript
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; }
|
|
.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`<span class="${className}">${category}</span>`;
|
|
}
|
|
|
|
private getStrategyBadgeHtml(strategy: string): any {
|
|
return html`<span class="strategy-badge">${strategy}</span>`;
|
|
}
|
|
|
|
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`
|
|
<cloudly-sectionheading>Services</cloudly-sectionheading>
|
|
<dees-table
|
|
.heading1=${'Services'}
|
|
.heading2=${'Service configuration and deployment management'}
|
|
.data=${this.data.services || []}
|
|
.displayFunction=${(itemArg: plugins.interfaces.data.IService) => {
|
|
return {
|
|
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: 'Details',
|
|
iconName: 'eye',
|
|
type: ['contextmenu', 'inRow', 'doubleClick'],
|
|
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`
|
|
<dees-form>
|
|
<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=${[{key: 'base', option: 'Base'}, {key: 'distributed', option: 'Distributed'}, {key: 'workload', option: 'Workload'}]} .value=${'workload'} .required=${true}></dees-input-dropdown>
|
|
<dees-input-dropdown .key=${'deploymentStrategy'} .label=${'Deployment Strategy'} .options=${[{key: 'all-nodes', option: 'All Nodes'}, {key: 'limited-replicas', option: 'Limited Replicas'}, {key: 'custom', option: 'Custom'}]} .value=${'custom'} .required=${true}></dees-input-dropdown>
|
|
<dees-input-text .key=${'maxReplicas'} .label=${'Max Replicas (for distributed services)'} .value=${'1'} .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=${[{key: 'round-robin', option: 'Round Robin'}, {key: 'least-connections', option: '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 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`
|
|
<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=${[{key: 'base', option: 'Base'}, {key: 'distributed', option: 'Distributed'}, {key: 'workload', option: 'Workload'}]} .value=${service.data.serviceCategory || 'workload'} .required=${true}></dees-input-dropdown>
|
|
<dees-input-dropdown .key=${'deploymentStrategy'} .label=${'Deployment Strategy'} .options=${[{key: 'all-nodes', option: 'All Nodes'}, {key: 'limited-replicas', option: 'Limited Replicas'}, {key: 'custom', option: '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=${[{key: 'round-robin', option: 'Round Robin'}, {key: 'least-connections', option: 'Least Connections'}]} .value=${service.data.balancingStrategy} .required=${true}></dees-input-dropdown>
|
|
</dees-form>
|
|
`,
|
|
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`
|
|
<div style="text-align:center">Are you sure you want to delete this service?</div>
|
|
<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', 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[]}
|
|
></dees-table>
|
|
`;
|
|
}
|
|
|
|
private renderDetailView(): TemplateResult {
|
|
const service = this.selectedService;
|
|
if (!service) {
|
|
return html`
|
|
<cloudly-sectionheading>Service Details</cloudly-sectionheading>
|
|
<button class="back-button" @click=${() => { this.currentView = 'list'; }}>Back to Services</button>
|
|
`;
|
|
}
|
|
|
|
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`
|
|
<cloudly-sectionheading>Service Details</cloudly-sectionheading>
|
|
<div class="detail-header">
|
|
<div>
|
|
<h2 class="detail-title">${service.data.name}</h2>
|
|
<div class="detail-subtitle">${service.data.description || 'No description configured'}</div>
|
|
</div>
|
|
<button class="back-button" @click=${() => { this.currentView = 'list'; }}>Back to Services</button>
|
|
</div>
|
|
|
|
${this.upgradeInfo ? html`
|
|
<div class="update-card">
|
|
<div>
|
|
<div class="section-title" style="margin-bottom: 3px;">App catalog update available</div>
|
|
<div class="detail-subtitle">${this.upgradeInfo.appTemplateId}: ${this.upgradeInfo.currentVersion} -> ${this.upgradeInfo.latestVersion}</div>
|
|
</div>
|
|
<button class="primary-button" disabled title="Cloudly does not yet have catalog upgrade apply support">Detected</button>
|
|
</div>
|
|
` : ''}
|
|
|
|
<div class="summary-grid">
|
|
<div class="summary-card">
|
|
<div class="summary-label">Running Deployments</div>
|
|
<div class="summary-value">${runningDeployments}/${desiredReplicas}</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<div class="summary-label">Image</div>
|
|
<div class="summary-value" style="font-size: 15px;">${service.data.imageId}:${service.data.imageVersion}</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<div class="summary-label">Strategy</div>
|
|
<div class="summary-value" style="font-size: 16px;">${service.data.deploymentStrategy}</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<div class="summary-label">Category</div>
|
|
<div class="summary-value" style="font-size: 16px;">${service.data.serviceCategory}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="detail-card">
|
|
<div style="display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px;">
|
|
<div>
|
|
<div class="section-title">Deployments</div>
|
|
<div class="detail-subtitle">Container-level runtime actions happen here.</div>
|
|
</div>
|
|
<button class="back-button" @click=${() => this.loadDeploymentsForService(service)}>Refresh</button>
|
|
</div>
|
|
${this.deploymentsLoading ? html`<div class="detail-subtitle">Loading deployments...</div>` : html`
|
|
<dees-table
|
|
.heading1=${'Live Deployments'}
|
|
.heading2=${this.serviceDeployments.length ? 'Docker Swarm tasks reported by connected Coreflows' : 'No live deployments reported'}
|
|
.data=${this.serviceDeployments}
|
|
.displayFunction=${(deploymentArg: any) => ({
|
|
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: 'Details',
|
|
iconName: 'lucide:Eye',
|
|
type: ['contextmenu', 'inRow', 'doubleClick'],
|
|
actionFunc: async (actionDataArg: any) => {
|
|
await this.showDeploymentDetailsModal(actionDataArg.item);
|
|
},
|
|
},
|
|
{
|
|
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[]}
|
|
></dees-table>
|
|
`}
|
|
</div>
|
|
|
|
<div class="details-grid">
|
|
<div class="detail-card">
|
|
<div class="section-title">Service Configuration</div>
|
|
<div class="kv-list">
|
|
<div class="kv-row"><span class="kv-key">Service ID</span><span class="kv-value">${service.id}</span></div>
|
|
<div class="kv-row"><span class="kv-key">Image ID</span><span class="kv-value">${service.data.imageId}</span></div>
|
|
<div class="kv-row"><span class="kv-key">Image Version</span><span class="kv-value">${service.data.imageVersion}</span></div>
|
|
<div class="kv-row"><span class="kv-key">Web Port</span><span class="kv-value">${service.data.ports?.web || '-'}</span></div>
|
|
<div class="kv-row"><span class="kv-key">Deploy on Push</span><span class="kv-value">${service.data.deployOnPush === false ? 'disabled' : 'enabled'}</span></div>
|
|
<div class="kv-row"><span class="kv-key">App Template</span><span class="kv-value">${serviceData.appTemplateId ? `${serviceData.appTemplateId}@${serviceData.appTemplateVersion}` : '-'}</span></div>
|
|
<div class="kv-row"><span class="kv-key">Registry Target</span><span class="kv-value">${service.data.registryTarget?.imageUrl || '-'}</span></div>
|
|
</div>
|
|
</div>
|
|
<div class="detail-card">
|
|
<div class="section-title">Routes, Volumes, Secrets</div>
|
|
<div class="kv-list">
|
|
<div class="kv-row"><span class="kv-key">Domains</span><span class="kv-value">${domains.length ? domains.map((domainArg) => domainArg.name).join(', ') : '-'}</span></div>
|
|
<div class="kv-row"><span class="kv-key">Volumes</span><span class="kv-value">${volumes.length ? volumes.map((volumeArg) => volumeArg.mountPath).join(', ') : '-'}</span></div>
|
|
<div class="kv-row"><span class="kv-key">Secret Bundle</span><span class="kv-value">${service.data.secretBundleId || '-'}</span></div>
|
|
<div class="kv-row"><span class="kv-key">Extra Bundles</span><span class="kv-value">${service.data.additionalSecretBundleIds?.length || 0}</span></div>
|
|
<div class="kv-row"><span class="kv-key">Env Keys</span><span class="kv-value">${Object.keys(service.data.environment || {}).join(', ') || '-'}</span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderWorkspaceView(): TemplateResult {
|
|
return html`
|
|
<cloudly-sectionheading>Deployment IDE</cloudly-sectionheading>
|
|
<div class="workspace-shell">
|
|
<div class="workspace-toolbar">
|
|
<div>
|
|
<div class="section-title">${this.selectedService?.data.name || 'Deployment'} workspace</div>
|
|
<div class="detail-subtitle">${this.workspaceDeployment?.containerId || this.workspaceDeployment?.id || ''}</div>
|
|
</div>
|
|
<button class="back-button" @click=${() => { this.currentView = 'detail'; }}>Back to Deployments</button>
|
|
</div>
|
|
${this.workspaceEnvironment
|
|
? html`<dees-workspace .executionEnvironment=${this.workspaceEnvironment}></dees-workspace>`
|
|
: html`<div class="detail-subtitle">Workspace is not available.</div>`}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderStatusBadge(statusArg: string): TemplateResult {
|
|
return html`<span class="status-badge status-${statusArg || 'scheduled'}">${statusArg || 'scheduled'}</span>`;
|
|
}
|
|
|
|
private formatDate(timestampArg?: number): string {
|
|
return timestampArg ? new Date(timestampArg).toLocaleString() : '-';
|
|
}
|
|
|
|
private formatResourceUsage(deploymentArg: plugins.interfaces.data.IDeployment): string {
|
|
if (!deploymentArg.resourceUsage) {
|
|
return '-';
|
|
}
|
|
return `${deploymentArg.resourceUsage.cpuUsagePercent.toFixed(1)}% CPU / ${deploymentArg.resourceUsage.memoryUsedMB} MB`;
|
|
}
|
|
|
|
private async showDeploymentDetailsModal(deploymentArg: plugins.interfaces.data.IDeployment) {
|
|
await plugins.deesCatalog.DeesModal.createAndShow({
|
|
heading: 'Deployment Details',
|
|
content: html`
|
|
<div class="kv-list" style="min-width: 520px;">
|
|
<div class="kv-row"><span class="kv-key">Deployment ID</span><span class="kv-value">${deploymentArg.id}</span></div>
|
|
<div class="kv-row"><span class="kv-key">Service</span><span class="kv-value">${deploymentArg.serviceName || this.selectedService?.data.name || deploymentArg.serviceId}</span></div>
|
|
<div class="kv-row"><span class="kv-key">Status</span><span class="kv-value">${deploymentArg.status}</span></div>
|
|
<div class="kv-row"><span class="kv-key">Health</span><span class="kv-value">${deploymentArg.healthStatus || '-'}</span></div>
|
|
<div class="kv-row"><span class="kv-key">Node</span><span class="kv-value">${deploymentArg.nodeName || deploymentArg.nodeId || '-'}</span></div>
|
|
<div class="kv-row"><span class="kv-key">Slot</span><span class="kv-value">${deploymentArg.slot || '-'}</span></div>
|
|
<div class="kv-row"><span class="kv-key">Desired State</span><span class="kv-value">${deploymentArg.desiredState || '-'}</span></div>
|
|
<div class="kv-row"><span class="kv-key">Container ID</span><span class="kv-value">${deploymentArg.containerId || '-'}</span></div>
|
|
<div class="kv-row"><span class="kv-key">Task ID</span><span class="kv-value">${deploymentArg.taskId || '-'}</span></div>
|
|
<div class="kv-row"><span class="kv-key">Docker Service ID</span><span class="kv-value">${deploymentArg.dockerServiceId || '-'}</span></div>
|
|
<div class="kv-row"><span class="kv-key">Version</span><span class="kv-value">${deploymentArg.version || '-'}</span></div>
|
|
<div class="kv-row"><span class="kv-key">Image</span><span class="kv-value">${deploymentArg.usedImageId || '-'}</span></div>
|
|
<div class="kv-row"><span class="kv-key">Resources</span><span class="kv-value">${this.formatResourceUsage(deploymentArg)}</span></div>
|
|
<div class="kv-row"><span class="kv-key">Deployed At</span><span class="kv-value">${this.formatDate(deploymentArg.deployedAt)}</span></div>
|
|
<div class="kv-row"><span class="kv-key">Updated At</span><span class="kv-value">${this.formatDate(deploymentArg.updatedAt)}</span></div>
|
|
</div>
|
|
`,
|
|
menuOptions: [
|
|
{ name: 'Close', action: async (modalArg: any) => modalArg.destroy() },
|
|
],
|
|
});
|
|
}
|
|
|
|
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('getUpgradeableAppStoreServices', {}) 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`
|
|
<div style="text-align: center; max-width: 520px;">
|
|
This kills the running container for deployment <strong>${deploymentArg.id}</strong>.
|
|
Docker Swarm may create a replacement task if the service still desires a replica.
|
|
</div>
|
|
`,
|
|
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<string, unknown>) {
|
|
const identity = appstate.loginStatePart.getState()?.identity;
|
|
if (!identity) {
|
|
throw new Error('Not logged in');
|
|
}
|
|
const typedRequest = new plugins.typedrequest.TypedRequest<any>(
|
|
'/typedrequest',
|
|
methodArg,
|
|
);
|
|
return await typedRequest.fire({
|
|
identity,
|
|
...dataArg,
|
|
});
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'cloudly-view-services': CloudlyViewServices;
|
|
}
|
|
}
|