feat(cloudly): add service runtime and onboarding

This commit is contained in:
2026-05-23 10:46:52 +00:00
parent 59043b7281
commit bef236cd86
18 changed files with 1500 additions and 19 deletions
+53
View File
@@ -27,6 +27,51 @@ export class CloudlyViewClusters extends DeesElement {
this.rxSubscriptions.push(subecription);
}
private async createJumpCommand(clusterArg: plugins.interfaces.data.ICluster) {
const identity = appstate.loginStatePart.getState()?.identity;
if (!identity) {
plugins.deesCatalog.DeesToast.createAndShow({ message: 'Login required to create a jump code', type: 'error' });
return;
}
try {
appstate.apiClient.identity = identity;
const apiClient = appstate.apiClient as any;
const response = apiClient.node?.createNodeJumpCommand
? await apiClient.node.createNodeJumpCommand({ clusterId: clusterArg.id })
: await apiClient.typedsocketClient
.createTypedRequest('createNodeJumpCommand')
.fire({ identity, clusterId: clusterArg.id });
await plugins.deesCatalog.DeesModal.createAndShow({
heading: 'Connect System',
content: html`
<div style="display: grid; gap: 16px; min-width: min(680px, 80vw);">
<div>
Connect a Linux system to <strong>${clusterArg.data.name}</strong> by running this command as an administrator.
</div>
<pre style="white-space: pre-wrap; word-break: break-all; background: #0d1117; border: 1px solid #30363d; border-radius: 12px; padding: 16px; color: #7ee787;">${response.command}</pre>
<div style="color: #9aa4b2; font-size: 13px;">
Jump URL: ${response.jumpUrl}<br>
Expires: ${new Date(response.expiresAt).toLocaleString()}
</div>
</div>
`,
menuOptions: [
{
name: 'copy command',
action: async () => {
await navigator.clipboard.writeText(response.command);
plugins.deesCatalog.DeesToast.createAndShow({ message: 'Jump command copied', type: 'success' });
},
},
{ name: 'close', action: async (modalArg: any) => modalArg.destroy() },
],
});
} catch (error: any) {
plugins.deesCatalog.DeesToast.createAndShow({ message: `Failed to create jump code: ${error.message}`, type: 'error' });
}
}
public static styles = [
cssManager.defaultStyles,
shared.viewHostCss,
@@ -97,6 +142,14 @@ export class CloudlyViewClusters extends DeesElement {
});
},
},
{
name: 'connect system',
iconName: 'terminal',
type: ['contextmenu', 'inRow'],
actionFunc: async (actionDataArg: any) => {
await this.createJumpCommand(actionDataArg.item as plugins.interfaces.data.ICluster);
},
},
] as plugins.deesCatalog.ITableAction[]}
></dees-table>
`;
+318 -2
View File
@@ -1,5 +1,6 @@
import * as plugins from '../../../plugins.js';
import * as shared from '../../shared/index.js';
import { DeploymentExecutionEnvironment } from '../../../environments/deployment-environment.js';
import {
DeesElement,
@@ -8,6 +9,7 @@ import {
state,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
import * as appstate from '../../../appstate.js';
@@ -17,6 +19,27 @@ 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
@@ -36,6 +59,33 @@ export class CloudlyViewServices extends DeesElement {
.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; } }
`,
];
@@ -57,7 +107,17 @@ export class CloudlyViewServices extends DeesElement {
return html`<span class="strategy-badge">${strategy}</span>`;
}
public render() {
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
@@ -66,7 +126,7 @@ export class CloudlyViewServices extends DeesElement {
.data=${this.data.services || []}
.displayFunction=${(itemArg: plugins.interfaces.data.IService) => {
return {
Name: itemArg.data.name,
Name: html`<button class="link-button" @click=${() => this.openServiceDetail(itemArg)}>${itemArg.data.name}</button>`,
Description: itemArg.data.description,
Category: this.getCategoryBadgeHtml(itemArg.data.serviceCategory || 'workload'),
'Deployment Strategy': html`
@@ -81,6 +141,14 @@ export class CloudlyViewServices extends DeesElement {
};
}}
.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',
@@ -216,6 +284,254 @@ export class CloudlyViewServices extends DeesElement {
></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: '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 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`
<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 {
@@ -0,0 +1,110 @@
import * as plugins from '../plugins.js';
type IExecutionEnvironment = import('@design.estate/dees-catalog').IExecutionEnvironment;
type IFileEntry = import('@design.estate/dees-catalog').IFileEntry;
type IFileWatcher = import('@design.estate/dees-catalog').IFileWatcher;
type IProcessHandle = import('@design.estate/dees-catalog').IProcessHandle;
type TTypedRequestShape = {
method: string;
request: Record<string, unknown>;
response: Record<string, unknown>;
};
export class DeploymentExecutionEnvironment implements IExecutionEnvironment {
public readonly type = 'backend' as const;
private readyState = false;
constructor(
private deploymentId: string,
private identity: plugins.interfaces.data.IIdentity,
) {}
get ready(): boolean {
return this.readyState;
}
public async init(): Promise<void> {
const result = await this.fireRequest('deploymentWorkspaceExists', { path: '/' }) as { exists: boolean };
if (!result.exists) {
throw new Error(`Cannot access deployment filesystem for ${this.deploymentId}`);
}
this.readyState = true;
}
public async destroy(): Promise<void> {
this.readyState = false;
}
public async readFile(pathArg: string): Promise<string> {
const result = await this.fireRequest('deploymentWorkspaceReadFile', { path: pathArg }) as { content: string };
return result.content;
}
public async writeFile(pathArg: string, contentsArg: string): Promise<void> {
await this.fireRequest('deploymentWorkspaceWriteFile', { path: pathArg, content: contentsArg });
}
public async readDir(pathArg: string): Promise<IFileEntry[]> {
const result = await this.fireRequest('deploymentWorkspaceReadDir', { path: pathArg }) as { entries: IFileEntry[] };
return result.entries;
}
public async mkdir(pathArg: string): Promise<void> {
await this.fireRequest('deploymentWorkspaceMkdir', { path: pathArg });
}
public async rm(pathArg: string, optionsArg?: { recursive?: boolean }): Promise<void> {
await this.fireRequest('deploymentWorkspaceRm', {
path: pathArg,
recursive: optionsArg?.recursive,
});
}
public async exists(pathArg: string): Promise<boolean> {
const result = await this.fireRequest('deploymentWorkspaceExists', { path: pathArg }) as { exists: boolean };
return result.exists;
}
public watch(
_pathArg: string,
_callbackArg: (eventArg: 'rename' | 'change', filenameArg: string | null) => void,
_optionsArg?: { recursive?: boolean },
): IFileWatcher {
return { stop: () => {} };
}
public async spawn(commandArg: string, argsArg: string[] = []): Promise<IProcessHandle> {
const result = await this.fireRequest('deploymentWorkspaceExec', {
command: commandArg,
args: argsArg,
}) as { stdout?: string; stderr?: string; exitCode: number };
const output = new ReadableStream<string>({
start(controllerArg) {
if (result.stdout) controllerArg.enqueue(result.stdout);
if (result.stderr) controllerArg.enqueue(result.stderr);
controllerArg.close();
},
});
return {
output,
input: new WritableStream<string>(),
exit: Promise.resolve(result.exitCode),
kill: () => {},
};
}
private async fireRequest(methodArg: string, dataArg: Record<string, unknown>) {
const typedRequest = new plugins.typedrequest.TypedRequest<TTypedRequestShape>(
'/typedrequest',
methodArg,
);
return await typedRequest.fire({
identity: this.identity,
deploymentId: this.deploymentId,
...dataArg,
});
}
}
+5
View File
@@ -11,6 +11,11 @@ import * as deesCatalog from '@design.estate/dees-catalog';
export { deesDomtools, deesElement, deesCatalog };
// @api.global scope
import * as typedrequest from '@api.global/typedrequest';
export { typedrequest };
// @push.rocks scope
import * as webjwt from '@push.rocks/webjwt';
import * as smartstate from '@push.rocks/smartstate';