Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d6d43b564 | |||
| bef236cd86 | |||
| 59043b7281 | |||
| befd0efdc0 |
+16
-2
@@ -14,7 +14,7 @@
|
||||
"outputMode": "bundle",
|
||||
"bundler": "esbuild",
|
||||
"production": true,
|
||||
"includeFiles": ["./html/**/*.html"]
|
||||
"includeFiles": ["./html/**/*"]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -45,7 +45,7 @@
|
||||
"triggerReload": true,
|
||||
"bundler": "esbuild",
|
||||
"production": false,
|
||||
"includeFiles": ["./html/**/*.html"]
|
||||
"includeFiles": ["./html/**/*"]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -62,6 +62,7 @@
|
||||
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||
},
|
||||
"@git.zone/cli": {
|
||||
"schemaVersion": 2,
|
||||
"projectType": "service",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
@@ -98,6 +99,19 @@
|
||||
"backend",
|
||||
"security"
|
||||
]
|
||||
},
|
||||
"release": {
|
||||
"targets": {
|
||||
"git": {
|
||||
"enabled": true,
|
||||
"remote": "origin"
|
||||
},
|
||||
"docker": {
|
||||
"enabled": true,
|
||||
"engine": "tsdocker",
|
||||
"patterns": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,26 @@
|
||||
## Pending
|
||||
|
||||
|
||||
## 2026-05-23 - 5.8.0
|
||||
|
||||
### Features
|
||||
|
||||
- add service detail runtime actions and app catalog onboarding
|
||||
- Adds service detail pages with live deployments, restart, kill, and deployment IDE access
|
||||
- Adds app catalog install/update detection contracts and Cloudly handlers
|
||||
- Adds node jump codes for connecting systems to clusters
|
||||
- Updates Cloudly to pnpm 11 and @serve.zone/interfaces 5.9.0
|
||||
|
||||
## 2026-05-21 - 5.7.1
|
||||
|
||||
### Fixes
|
||||
|
||||
- clean up Cloudly dashboard console and asset errors
|
||||
- replace invalid Lucide icon references in table actions and context menu items
|
||||
- add PWA manifest and local SVG favicon assets to avoid 404s
|
||||
- align local Cloudly custom element tags with canonical kebab-case names
|
||||
- remove noisy frontend debug logging from login and revenue checks
|
||||
|
||||
## 2026-05-21 - 5.7.0
|
||||
|
||||
### Features
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Cloudly">
|
||||
<rect width="64" height="64" rx="14" fill="#050505"/>
|
||||
<path d="M19 40h27a9 9 0 0 0 1.5-17.9A14 14 0 0 0 20.2 17 11.5 11.5 0 0 0 19 40Z" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M23 32h18" stroke="#7dd3fc" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 416 B |
+1
-2
@@ -14,7 +14,7 @@
|
||||
|
||||
<!--Lets make sure we recognize this as an PWA-->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" type="image/png" href="/assetbroker/manifest/favicon.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
||||
<!--Lets load standard fonts-->
|
||||
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
|
||||
@@ -114,7 +114,6 @@
|
||||
} else {
|
||||
window.revenueEnabled = false;
|
||||
}
|
||||
console.log(`revenue enabled: ${window.revenueEnabled}`);
|
||||
};
|
||||
|
||||
runRevenueCheck();
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Cloudly",
|
||||
"short_name": "Cloudly",
|
||||
"description": "Cloudly infrastructure management dashboard",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#000000",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/cloudly",
|
||||
"version": "5.7.0",
|
||||
"version": "5.8.0",
|
||||
"private": true,
|
||||
"description": "A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.",
|
||||
"type": "module",
|
||||
@@ -79,7 +79,7 @@
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@push.rocks/webjwt": "^1.0.10",
|
||||
"@serve.zone/api": "^5.3.7",
|
||||
"@serve.zone/interfaces": "^5.6.0",
|
||||
"@serve.zone/interfaces": "^5.9.0",
|
||||
"@tsclass/tsclass": "^9.5.1"
|
||||
},
|
||||
"files": [
|
||||
@@ -134,5 +134,5 @@
|
||||
"backend",
|
||||
"security"
|
||||
],
|
||||
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6"
|
||||
"packageManager": "pnpm@11.2.2"
|
||||
}
|
||||
|
||||
Generated
+11
-2
@@ -144,8 +144,8 @@ importers:
|
||||
specifier: ^5.3.7
|
||||
version: 5.3.7(@push.rocks/smartserve@2.0.4)
|
||||
'@serve.zone/interfaces':
|
||||
specifier: ^5.6.0
|
||||
version: 5.6.0
|
||||
specifier: ^5.9.0
|
||||
version: 5.9.0
|
||||
'@tsclass/tsclass':
|
||||
specifier: ^9.5.1
|
||||
version: 9.5.1
|
||||
@@ -1697,6 +1697,9 @@ packages:
|
||||
'@serve.zone/interfaces@5.6.0':
|
||||
resolution: {integrity: sha512-4ewYkGZU0rxWuCs32M/UtENWNzyPqpAad0YlFZ74h3IW1mKjryPMMHcEjdWqR5yArJAUMJR1oGk71XkGElNzDQ==}
|
||||
|
||||
'@serve.zone/interfaces@5.9.0':
|
||||
resolution: {integrity: sha512-XMXyTXTMcB8AX6zYOMO+Jt5bOv9ujyXj5miE6lrgyT8g+eJ/I6sUFqVNUKJ3LiMk/yFWsPln7HtZeZKDEhaCwQ==}
|
||||
|
||||
'@shikijs/engine-oniguruma@3.23.0':
|
||||
resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
|
||||
|
||||
@@ -7175,6 +7178,12 @@ snapshots:
|
||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||
'@tsclass/tsclass': 9.5.1
|
||||
|
||||
'@serve.zone/interfaces@5.9.0':
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||
'@tsclass/tsclass': 9.5.1
|
||||
|
||||
'@shikijs/engine-oniguruma@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
allowBuilds:
|
||||
'@design.estate/dees-catalog': false
|
||||
cpu-features: true
|
||||
esbuild: true
|
||||
mongodb-memory-server: false
|
||||
puppeteer: false
|
||||
sharp: false
|
||||
ssh2: true
|
||||
@@ -52,6 +52,7 @@ Cloudly currently coordinates these areas:
|
||||
| `CloudflareConnector` | Optional Cloudflare account used by ACME DNS-01 when `cloudflareToken` is configured in settings. |
|
||||
| `LetsencryptConnector` | SmartACME certificate issuance and certificate lookup. |
|
||||
| `CloudlyCoreflowManager` | Authenticates Coreflow, returns cluster config payloads, and pushes config updates to connected Coreflow clients. |
|
||||
| `CloudlyJumpManager` | Creates short-lived Jump Codes for onboarding existing systems into clusters. |
|
||||
| `CloudlyRegistryManager` | Embedded OCI registry backed by configured S3 storage, including deploy-on-push metadata updates. |
|
||||
| `CloudlyBaseOsManager` | BaseOS registration, heartbeat, image build orchestration, worker selection, and artifact downloads. |
|
||||
| `CloudlyBackupManager` | Service backup/restore orchestration and remote archive object replication. |
|
||||
@@ -194,6 +195,18 @@ The implemented cluster flow is intentionally simple:
|
||||
|
||||
When service, platform, or gateway settings change, Cloudly pushes updated config to connected Coreflow clients where supported.
|
||||
|
||||
### Jump Codes for Existing Systems
|
||||
|
||||
Admins can generate a short-lived, single-use Jump Code for a cluster. The dashboard displays a command in this form:
|
||||
|
||||
```sh
|
||||
curl -fsSL 'https://cloudly.example.com/jump/<code>' | sudo bash
|
||||
```
|
||||
|
||||
The public `/jump/<code>` URL renders a browser landing page for humans and a shell bootstrap script for `curl`/CLI clients. The script installs the required host tooling, claims the code through `POST /jump/v1/claim`, receives the cluster runtime token, and starts Spark in `coreflow-node` mode. The long-lived cluster token is never displayed in the dashboard command.
|
||||
|
||||
Jump Codes expire by default after 30 minutes and are consumed on first successful claim.
|
||||
|
||||
## Registry and Deploy-On-Push
|
||||
|
||||
Cloudly serves an OCI registry under `/v2` through `CloudlyRegistryManager`. The registry uses configured S3 storage and issues OCI tokens from Cloudly authentication state.
|
||||
|
||||
@@ -92,6 +92,58 @@ tap.test('should get an identity', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should create and consume node jump codes', async () => {
|
||||
const cluster = await testClient.cluster.createCluster('Jump Code Test Cluster');
|
||||
const createJumpCommandTR = testClient.typedsocketClient.createTypedRequest<any>('createNodeJumpCommand');
|
||||
const jumpCommand = await createJumpCommandTR.fire({
|
||||
identity: testClient.identity,
|
||||
clusterId: cluster.id,
|
||||
});
|
||||
|
||||
expect(jumpCommand.jumpUrl.includes('/jump/')).toBeTrue();
|
||||
expect(jumpCommand.command.includes(jumpCommand.jumpUrl)).toBeTrue();
|
||||
|
||||
const setupResponse = await fetch(jumpCommand.jumpUrl, {
|
||||
headers: {
|
||||
accept: '*/*',
|
||||
'user-agent': 'curl/8.0',
|
||||
},
|
||||
});
|
||||
const setupScript = await setupResponse.text();
|
||||
expect(setupResponse.status).toEqual(200);
|
||||
expect(setupScript.includes('spark installdaemon --mode=coreflow-node')).toBeTrue();
|
||||
|
||||
const claimResponse = await fetch(
|
||||
`http://${helpers.testCloudlyConfig.publicUrl}:${helpers.testCloudlyConfig.publicPort}/jump/v1/claim`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ jumpCode: jumpCommand.jumpCode, hostname: 'jump-code-test-node' }),
|
||||
},
|
||||
);
|
||||
const claimBody = await claimResponse.json();
|
||||
expect(claimResponse.status).toEqual(200);
|
||||
expect(claimBody.accepted).toBeTrue();
|
||||
expect(claimBody.nodeId).toBeTruthy();
|
||||
expect(claimBody.coreflowJumpCode).toBeTruthy();
|
||||
|
||||
const secondClaimResponse = await fetch(
|
||||
`http://${helpers.testCloudlyConfig.publicUrl}:${helpers.testCloudlyConfig.publicPort}/jump/v1/claim`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ jumpCode: jumpCommand.jumpCode }),
|
||||
},
|
||||
);
|
||||
const secondClaimBody = await secondClaimResponse.json();
|
||||
expect(secondClaimResponse.status).toEqual(400);
|
||||
expect(secondClaimBody.accepted).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('should expose the OCI registry endpoint', async () => {
|
||||
const response = await fetch(
|
||||
`http://${helpers.testCloudlyConfig.publicUrl}:${helpers.testCloudlyConfig.publicPort}/v2/`,
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/cloudly',
|
||||
version: '5.7.0',
|
||||
version: '5.8.0',
|
||||
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ import { CloudlySettingsManager } from './manager.settings/classes.settingsmanag
|
||||
import { CloudlyPlatformManager } from './manager.platform/classes.platformmanager.js';
|
||||
import { CloudlyBackupManager } from './manager.backup/classes.backupmanager.js';
|
||||
import { CloudlyBaseOsManager } from './manager.baseos/classes.baseosmanager.js';
|
||||
import { CloudlyAppCatalogManager } from './manager.appcatalog/classes.appcatalogmanager.js';
|
||||
import { CloudlyJumpManager } from './manager.jump/classes.jumpmanager.js';
|
||||
|
||||
/**
|
||||
* Cloudly class can be used to instantiate a cloudly server.
|
||||
@@ -79,6 +81,8 @@ export class Cloudly {
|
||||
public nodeManager: CloudlyNodeManager;
|
||||
public baremetalManager: CloudlyBaremetalManager;
|
||||
public baseOsManager: CloudlyBaseOsManager;
|
||||
public appCatalogManager: CloudlyAppCatalogManager;
|
||||
public jumpManager: CloudlyJumpManager;
|
||||
|
||||
private readyDeferred = new plugins.smartpromise.Deferred();
|
||||
|
||||
@@ -115,8 +119,10 @@ export class Cloudly {
|
||||
this.backupManager = new CloudlyBackupManager(this);
|
||||
this.baseOsManager = new CloudlyBaseOsManager(this);
|
||||
this.secretManager = new CloudlySecretManager(this);
|
||||
this.appCatalogManager = new CloudlyAppCatalogManager(this);
|
||||
this.nodeManager = new CloudlyNodeManager(this);
|
||||
this.baremetalManager = new CloudlyBaremetalManager(this);
|
||||
this.jumpManager = new CloudlyJumpManager(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,12 +144,14 @@ export class Cloudly {
|
||||
await this.secretManager.start();
|
||||
await this.nodeManager.start();
|
||||
await this.baremetalManager.start();
|
||||
await this.jumpManager.start();
|
||||
await this.serviceManager.start();
|
||||
await this.platformManager.start();
|
||||
await this.deploymentManager.start();
|
||||
await this.taskManager.init();
|
||||
await this.backupManager.start();
|
||||
await this.baseOsManager.start();
|
||||
await this.appCatalogManager.start();
|
||||
await this.registryManager.start();
|
||||
await this.domainManager.init();
|
||||
|
||||
@@ -173,10 +181,12 @@ export class Cloudly {
|
||||
await this.serviceManager.stop();
|
||||
await this.platformManager.stop();
|
||||
await this.deploymentManager.stop();
|
||||
await this.jumpManager.stop();
|
||||
await this.taskManager.stop();
|
||||
await this.backupManager.stop();
|
||||
await this.baseOsManager.stop();
|
||||
await this.registryManager.stop();
|
||||
await this.appCatalogManager.stop();
|
||||
await this.externalRegistryManager.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,21 @@ export class CloudlyServer {
|
||||
'ALL',
|
||||
async (ctx) => this.cloudlyRef.nodeManager.curlfreshInstance.handleRequest(ctx),
|
||||
);
|
||||
this.typedServer.addRoute(
|
||||
'/jump/v1/claim',
|
||||
'POST',
|
||||
async (ctx) => this.cloudlyRef.jumpManager.handleClaimHttpRequest(ctx),
|
||||
);
|
||||
this.typedServer.addRoute(
|
||||
'/jump/:code/setup.sh',
|
||||
'GET',
|
||||
async (ctx) => this.cloudlyRef.jumpManager.handleSetupScriptHttpRequest(ctx),
|
||||
);
|
||||
this.typedServer.addRoute(
|
||||
'/jump/:code',
|
||||
'GET',
|
||||
async (ctx) => this.cloudlyRef.jumpManager.handleJumpHttpRequest(ctx),
|
||||
);
|
||||
this.typedServer.addRoute(
|
||||
'/baseos/v1/nodes/register',
|
||||
'POST',
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import type { Cloudly } from '../classes.cloudly.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Image } from '../manager.image/classes.image.js';
|
||||
import { Service } from '../manager.service/classes.service.js';
|
||||
import { SecretBundle } from '../manager.secret/classes.secretbundle.js';
|
||||
import { PlatformBinding } from '../manager.platform/classes.platformbinding.js';
|
||||
|
||||
type ICatalogApp = plugins.servezoneInterfaces.appcatalog.ICatalogApp;
|
||||
type ICatalog = plugins.servezoneInterfaces.appcatalog.ICatalog;
|
||||
type IAppMeta = plugins.servezoneInterfaces.appcatalog.IAppMeta;
|
||||
type IAppVersionConfig = plugins.servezoneInterfaces.appcatalog.IAppVersionConfig;
|
||||
type IInstallOptions = plugins.servezoneInterfaces.appcatalog.IAppInstallRequest;
|
||||
type IUpgradeableCatalogService = plugins.servezoneInterfaces.appcatalog.IUpgradeableAppService;
|
||||
|
||||
export class CloudlyAppCatalogManager {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
private catalogCache: ICatalog | null = null;
|
||||
private lastFetchTime = 0;
|
||||
private readonly repoBaseUrl = process.env.APPCATALOG_URL || 'https://code.foss.global/serve.zone/appstore-apptemplates/raw/branch/main';
|
||||
private readonly cacheTtlMs = 5 * 60 * 1000;
|
||||
|
||||
constructor(private cloudlyRef: Cloudly) {
|
||||
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
public async start() {}
|
||||
public async stop() {}
|
||||
|
||||
private registerHandlers() {
|
||||
const addCatalogListHandler = (methodArg: string) => {
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<any>(methodArg, async (dataArg) => {
|
||||
await this.passAdminIdentity(dataArg);
|
||||
return { apps: await this.getApps() };
|
||||
}),
|
||||
);
|
||||
};
|
||||
addCatalogListHandler('getAppCatalogTemplates');
|
||||
addCatalogListHandler('getAppTemplates');
|
||||
|
||||
const addConfigHandler = (methodArg: string) => {
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<any>(methodArg, async (dataArg) => {
|
||||
await this.passAdminIdentity(dataArg);
|
||||
return {
|
||||
config: await this.getAppVersionConfig(dataArg.appId, dataArg.version),
|
||||
appMeta: await this.getAppMeta(dataArg.appId),
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
addConfigHandler('getAppCatalogConfig');
|
||||
addConfigHandler('getAppConfig');
|
||||
|
||||
const addInstallHandler = (methodArg: string) => {
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<any>(methodArg, async (dataArg) => {
|
||||
await this.passAdminIdentity(dataArg);
|
||||
const service = await this.installApp(dataArg.install || dataArg);
|
||||
return { service: await service.createSavableObject() };
|
||||
}),
|
||||
);
|
||||
};
|
||||
addInstallHandler('installAppCatalogApp');
|
||||
addInstallHandler('installAppTemplate');
|
||||
|
||||
const addUpgradeableHandler = (methodArg: string) => {
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<any>(methodArg, async (dataArg) => {
|
||||
await this.passAdminIdentity(dataArg);
|
||||
return { services: await this.getUpgradeableServices() };
|
||||
}),
|
||||
);
|
||||
};
|
||||
addUpgradeableHandler('getUpgradeableAppCatalogServices');
|
||||
addUpgradeableHandler('getUpgradeableServices');
|
||||
}
|
||||
|
||||
public async getCatalog(): Promise<ICatalog> {
|
||||
const now = Date.now();
|
||||
if (this.catalogCache && now - this.lastFetchTime < this.cacheTtlMs) {
|
||||
return this.catalogCache;
|
||||
}
|
||||
const catalog = await this.fetchJson('catalog.json') as ICatalog;
|
||||
if (!catalog || !Array.isArray(catalog.apps)) {
|
||||
throw new Error('Invalid app catalog format');
|
||||
}
|
||||
this.catalogCache = catalog;
|
||||
this.lastFetchTime = now;
|
||||
return catalog;
|
||||
}
|
||||
|
||||
public async getApps(): Promise<ICatalogApp[]> {
|
||||
return (await this.getCatalog()).apps;
|
||||
}
|
||||
|
||||
public async getAppMeta(appIdArg: string): Promise<IAppMeta> {
|
||||
return await this.fetchJson(`apps/${appIdArg}/app.json`) as IAppMeta;
|
||||
}
|
||||
|
||||
public async getAppVersionConfig(appIdArg: string, versionArg?: string): Promise<IAppVersionConfig> {
|
||||
if (!versionArg) {
|
||||
versionArg = (await this.getAppMeta(appIdArg)).latestVersion;
|
||||
}
|
||||
return await this.fetchJson(`apps/${appIdArg}/versions/${versionArg}/config.json`) as IAppVersionConfig;
|
||||
}
|
||||
|
||||
public async getUpgradeableServices(): Promise<IUpgradeableCatalogService[]> {
|
||||
const catalog = await this.getCatalog();
|
||||
const services = await this.cloudlyRef.serviceManager.CService.getInstances({});
|
||||
const upgradeableServices: IUpgradeableCatalogService[] = [];
|
||||
|
||||
for (const service of services) {
|
||||
const serviceData = service.data as plugins.servezoneInterfaces.data.IService['data'] & {
|
||||
appTemplateId?: string;
|
||||
appTemplateVersion?: string;
|
||||
};
|
||||
if (!serviceData.appTemplateId || !serviceData.appTemplateVersion) {
|
||||
continue;
|
||||
}
|
||||
const catalogApp = catalog.apps.find((appArg) => appArg.id === serviceData.appTemplateId);
|
||||
if (!catalogApp || catalogApp.latestVersion === serviceData.appTemplateVersion) {
|
||||
continue;
|
||||
}
|
||||
upgradeableServices.push({
|
||||
serviceName: serviceData.name,
|
||||
appTemplateId: serviceData.appTemplateId,
|
||||
currentVersion: serviceData.appTemplateVersion,
|
||||
latestVersion: catalogApp.latestVersion,
|
||||
hasMigration: false,
|
||||
});
|
||||
}
|
||||
|
||||
return upgradeableServices;
|
||||
}
|
||||
|
||||
public async installApp(optionsArg: IInstallOptions): Promise<Service> {
|
||||
const appMeta = await this.getAppMeta(optionsArg.appId);
|
||||
const version = optionsArg.version || appMeta.latestVersion;
|
||||
const config = await this.getAppVersionConfig(optionsArg.appId, version);
|
||||
const webPort = optionsArg.port || config.port;
|
||||
this.assertSupportedPlatformRequirements(config);
|
||||
const envVars = this.getCatalogEnvVars(config, optionsArg.envVars || {});
|
||||
if (this.requiresTemplateValue(envVars, 'SERVICE_DOMAIN') && !optionsArg.domain) {
|
||||
throw new Error('A domain is required because the app template uses ${SERVICE_DOMAIN}');
|
||||
}
|
||||
|
||||
const image = await this.createCatalogImage(optionsArg.serviceName, config.image, appMeta.description);
|
||||
const secretBundle = await this.createServiceSecretBundle(optionsArg.serviceName, image.id);
|
||||
const serviceData = {
|
||||
name: optionsArg.serviceName,
|
||||
description: appMeta.description,
|
||||
imageId: image.id,
|
||||
imageVersion: this.getImageTag(config.image),
|
||||
deployOnPush: false,
|
||||
appTemplateId: optionsArg.appId,
|
||||
appTemplateVersion: version,
|
||||
environment: envVars,
|
||||
secretBundleId: secretBundle.id,
|
||||
additionalSecretBundleIds: [],
|
||||
serviceCategory: 'workload',
|
||||
deploymentStrategy: 'limited-replicas',
|
||||
maxReplicas: 1,
|
||||
antiAffinity: false,
|
||||
scaleFactor: 1,
|
||||
balancingStrategy: 'round-robin',
|
||||
ports: { web: webPort },
|
||||
volumes: this.normalizeVolumes(config.volumes),
|
||||
domains: optionsArg.domain ? [{ name: optionsArg.domain, port: webPort, protocol: 'https' }] : [],
|
||||
deploymentIds: [],
|
||||
} as plugins.servezoneInterfaces.data.IService['data'] & {
|
||||
appTemplateId: string;
|
||||
appTemplateVersion: string;
|
||||
};
|
||||
const service = await Service.createService(serviceData);
|
||||
secretBundle.data.serviceId = service.id;
|
||||
await secretBundle.save();
|
||||
await this.createPlatformBindings(service, config);
|
||||
await this.cloudlyRef.coreflowManager.pushClusterConfigToConnectedCoreflows();
|
||||
return service;
|
||||
}
|
||||
|
||||
private async createCatalogImage(serviceNameArg: string, imageRefArg: string, descriptionArg: string): Promise<Image> {
|
||||
const image = new Image();
|
||||
image.id = await Image.getNewId();
|
||||
image.data = {
|
||||
name: `${serviceNameArg}-catalog-image`,
|
||||
description: descriptionArg,
|
||||
location: {
|
||||
internal: false,
|
||||
externalRegistryId: '',
|
||||
externalImageTag: imageRefArg,
|
||||
externalImageRef: imageRefArg,
|
||||
},
|
||||
versions: [{
|
||||
versionString: this.getImageTag(imageRefArg),
|
||||
source: 'registry',
|
||||
registryRepository: imageRefArg,
|
||||
registryTag: this.getImageTag(imageRefArg),
|
||||
size: 0,
|
||||
createdAt: Date.now(),
|
||||
}],
|
||||
};
|
||||
await image.save();
|
||||
return image;
|
||||
}
|
||||
|
||||
private async createServiceSecretBundle(serviceNameArg: string, imageIdArg: string): Promise<SecretBundle> {
|
||||
const secretBundle = new SecretBundle();
|
||||
secretBundle.id = plugins.smartunique.shortId(8);
|
||||
secretBundle.data = {
|
||||
name: `${serviceNameArg} catalog secrets`,
|
||||
description: `Generated catalog secret bundle for ${serviceNameArg}`,
|
||||
type: 'service',
|
||||
includedSecretGroupIds: [],
|
||||
includedTags: [],
|
||||
imageClaims: [{ imageId: imageIdArg, permissions: ['read'] }],
|
||||
authorizations: [],
|
||||
};
|
||||
await secretBundle.save();
|
||||
return secretBundle;
|
||||
}
|
||||
|
||||
private async createPlatformBindings(serviceArg: Service, configArg: IAppVersionConfig) {
|
||||
const requirements = configArg.platformRequirements || {};
|
||||
if (requirements.mongodb) {
|
||||
await PlatformBinding.upsertBinding({
|
||||
id: await PlatformBinding.getNewId(),
|
||||
serviceId: serviceArg.id,
|
||||
capability: 'database',
|
||||
desiredState: 'enabled',
|
||||
status: 'requested',
|
||||
});
|
||||
}
|
||||
if (requirements.s3) {
|
||||
await PlatformBinding.upsertBinding({
|
||||
id: await PlatformBinding.getNewId(),
|
||||
serviceId: serviceArg.id,
|
||||
capability: 'objectstorage',
|
||||
desiredState: 'enabled',
|
||||
status: 'requested',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeVolumes(volumesArg: IAppVersionConfig['volumes'] = []) {
|
||||
return volumesArg.map((volumeArg) => {
|
||||
if (typeof volumeArg === 'string') {
|
||||
return { mountPath: volumeArg };
|
||||
}
|
||||
return volumeArg;
|
||||
}).filter((volumeArg) => Boolean(volumeArg.mountPath));
|
||||
}
|
||||
|
||||
private getCatalogEnvVars(configArg: IAppVersionConfig, overridesArg: Record<string, string>): Record<string, string> {
|
||||
const envVars: Record<string, string> = {};
|
||||
const missingRequiredEnvVars: string[] = [];
|
||||
for (const envVar of configArg.envVars || []) {
|
||||
const value = overridesArg[envVar.key] ?? envVar.value ?? '';
|
||||
if (envVar.required && !value) {
|
||||
missingRequiredEnvVars.push(envVar.key);
|
||||
}
|
||||
envVars[envVar.key] = value;
|
||||
}
|
||||
Object.assign(envVars, overridesArg);
|
||||
if (missingRequiredEnvVars.length > 0) {
|
||||
throw new Error(`Missing required app env var(s): ${missingRequiredEnvVars.join(', ')}`);
|
||||
}
|
||||
return envVars;
|
||||
}
|
||||
|
||||
private requiresTemplateValue(envVarsArg: Record<string, string>, templateNameArg: string): boolean {
|
||||
return Object.values(envVarsArg).some((value) => value.includes(`\${${templateNameArg}}`));
|
||||
}
|
||||
|
||||
private assertSupportedPlatformRequirements(configArg: IAppVersionConfig) {
|
||||
const unsupported = Object.entries(configArg.platformRequirements || {})
|
||||
.filter(([key, enabled]) => enabled && key !== 'mongodb' && key !== 's3')
|
||||
.map(([key]) => key);
|
||||
if (unsupported.length > 0) {
|
||||
throw new Error(`Cloudly catalog install does not yet support platform requirement(s): ${unsupported.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getImageTag(imageRefArg: string) {
|
||||
const lastSlashIndex = imageRefArg.lastIndexOf('/');
|
||||
const lastColonIndex = imageRefArg.lastIndexOf(':');
|
||||
return lastColonIndex > lastSlashIndex ? imageRefArg.slice(lastColonIndex + 1) || 'latest' : 'latest';
|
||||
}
|
||||
|
||||
private async passAdminIdentity(dataArg: { identity: plugins.servezoneInterfaces.data.IIdentity }) {
|
||||
await plugins.smartguard.passGuardsOrReject(dataArg, [
|
||||
this.cloudlyRef.authManager.adminIdentityGuard,
|
||||
]);
|
||||
}
|
||||
|
||||
private async fetchJson(pathArg: string): Promise<unknown> {
|
||||
const url = `${this.repoBaseUrl.replace(/\/+$/, '')}/${pathArg}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for ${url}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,30 @@ import { Cloudly } from '../classes.cloudly.js';
|
||||
import type { Cluster } from '../manager.cluster/classes.cluster.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
type TCoreflowDeploymentRequest =
|
||||
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_RestartDeployment
|
||||
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_KillDeployment
|
||||
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceReadFile
|
||||
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceWriteFile
|
||||
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceReadDir
|
||||
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceMkdir
|
||||
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceRm
|
||||
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceExists
|
||||
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceExec;
|
||||
|
||||
type TCoreflowDeploymentActionMethod =
|
||||
| 'coreflowRestartDeployment'
|
||||
| 'coreflowKillDeployment';
|
||||
|
||||
type TCoreflowDeploymentActionRequest = Extract<TCoreflowDeploymentRequest, {
|
||||
method: TCoreflowDeploymentActionMethod;
|
||||
}>;
|
||||
|
||||
export type TCoreflowDeploymentWorkspaceMethod = Exclude<
|
||||
TCoreflowDeploymentRequest['method'],
|
||||
TCoreflowDeploymentActionMethod
|
||||
>;
|
||||
|
||||
/**
|
||||
* in charge of talking to coreflow services on clusters
|
||||
* coreflow runs on a server when ServerManager is done.
|
||||
@@ -159,4 +183,87 @@ export class CloudlyCoreflowManager {
|
||||
|
||||
return connections.length;
|
||||
}
|
||||
|
||||
public async getRuntimeDeploymentsForService(
|
||||
serviceArg: plugins.servezoneInterfaces.data.IService,
|
||||
): Promise<plugins.servezoneInterfaces.data.IDeployment[]> {
|
||||
const connections = await this.getConnectedCoreflowConnections();
|
||||
const deployments: plugins.servezoneInterfaces.data.IDeployment[] = [];
|
||||
for (const connection of connections) {
|
||||
try {
|
||||
const request = this.cloudlyRef.server.typedServer.typedsocket.createTypedRequest<plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_GetServiceDeployments>(
|
||||
'coreflowGetServiceDeployments',
|
||||
connection,
|
||||
);
|
||||
const response = await request.fire({ service: serviceArg });
|
||||
deployments.push(...(response.deployments || []));
|
||||
} catch (error) {
|
||||
logger.log('warn', `failed to query coreflow deployments: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
return deployments;
|
||||
}
|
||||
|
||||
public async fireDeploymentRuntimeAction(
|
||||
methodArg: TCoreflowDeploymentActionMethod,
|
||||
deploymentIdArg: string,
|
||||
): Promise<{ deployment: plugins.servezoneInterfaces.data.IDeployment }> {
|
||||
const response = await this.fireCoreflowRequestUntilFound<TCoreflowDeploymentActionRequest>(methodArg, {
|
||||
deploymentId: deploymentIdArg,
|
||||
});
|
||||
if (!response.deployment) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Coreflow did not return deployment data');
|
||||
}
|
||||
return { deployment: response.deployment };
|
||||
}
|
||||
|
||||
public async fireDeploymentWorkspaceRequest(
|
||||
methodArg: TCoreflowDeploymentWorkspaceMethod,
|
||||
payloadArg: Extract<TCoreflowDeploymentRequest, { method: typeof methodArg }>['request'],
|
||||
) {
|
||||
return await this.fireCoreflowRequestUntilFound(methodArg, payloadArg);
|
||||
}
|
||||
|
||||
private async fireCoreflowRequestUntilFound<TRequest extends TCoreflowDeploymentRequest>(
|
||||
methodArg: TRequest['method'],
|
||||
payloadArg: TRequest['request'],
|
||||
): Promise<TRequest['response']> {
|
||||
const connections = await this.getConnectedCoreflowConnections();
|
||||
if (connections.length === 0) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No connected coreflow');
|
||||
}
|
||||
|
||||
let lastError: Error | undefined;
|
||||
for (const connection of connections) {
|
||||
try {
|
||||
const request = this.cloudlyRef.server.typedServer.typedsocket.createTypedRequest<TRequest>(
|
||||
methodArg,
|
||||
connection,
|
||||
);
|
||||
const response = await request.fire(payloadArg);
|
||||
if (response?.found) {
|
||||
return response;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
lastError?.message || 'No connected coreflow found the requested deployment',
|
||||
);
|
||||
}
|
||||
|
||||
private async getConnectedCoreflowConnections() {
|
||||
const typedsocket = this.cloudlyRef.server.typedServer?.typedsocket;
|
||||
if (!typedsocket) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await typedsocket.findAllTargetConnections(async (connectionArg) => {
|
||||
const identityTag = await connectionArg.getTagById('identity');
|
||||
const identity = identityTag?.payload as plugins.servezoneInterfaces.data.IIdentity | undefined;
|
||||
return identity?.role === 'cluster' && !!identity.userId;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Cloudly } from '../classes.cloudly.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Deployment } from './classes.deployment.js';
|
||||
import type { TCoreflowDeploymentWorkspaceMethod } from '../manager.coreflow/coreflowmanager.js';
|
||||
|
||||
export class DeploymentManager {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -71,6 +72,18 @@ export class DeploymentManager {
|
||||
this.cloudlyRef.authManager.validIdentityGuard,
|
||||
]);
|
||||
|
||||
const service = await this.cloudlyRef.serviceManager.CService.getInstance({
|
||||
id: reqArg.serviceId,
|
||||
});
|
||||
if (service) {
|
||||
const runtimeDeployments = await this.cloudlyRef.coreflowManager.getRuntimeDeploymentsForService(
|
||||
await service.createSavableObject(),
|
||||
);
|
||||
if (runtimeDeployments.length > 0) {
|
||||
return { deployments: runtimeDeployments };
|
||||
}
|
||||
}
|
||||
|
||||
const deployments = await this.CDeployment.getInstances({
|
||||
serviceId: reqArg.serviceId,
|
||||
});
|
||||
@@ -204,29 +217,41 @@ export class DeploymentManager {
|
||||
'restartDeployment',
|
||||
async (reqArg) => {
|
||||
await plugins.smartguard.passGuardsOrReject(reqArg, [
|
||||
this.cloudlyRef.authManager.validIdentityGuard,
|
||||
this.cloudlyRef.authManager.adminIdentityGuard,
|
||||
]);
|
||||
|
||||
const deployment = await this.CDeployment.getInstance({
|
||||
id: reqArg.deploymentId,
|
||||
});
|
||||
|
||||
if (!deployment) {
|
||||
throw new Error('Deployment not found');
|
||||
}
|
||||
|
||||
// TODO: Implement actual restart logic with Docker/container runtime
|
||||
deployment.status = 'starting';
|
||||
await deployment.save();
|
||||
const result = await this.cloudlyRef.coreflowManager.fireDeploymentRuntimeAction(
|
||||
'coreflowRestartDeployment',
|
||||
reqArg.deploymentId,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deployment: await deployment.createSavableObject(),
|
||||
deployment: result.deployment,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.deployment.IReq_Any_Cloudly_KillDeployment>(
|
||||
'killDeployment',
|
||||
async (reqArg) => {
|
||||
await plugins.smartguard.passGuardsOrReject(reqArg, [
|
||||
this.cloudlyRef.authManager.adminIdentityGuard,
|
||||
]);
|
||||
const result = await this.cloudlyRef.coreflowManager.fireDeploymentRuntimeAction(
|
||||
'coreflowKillDeployment',
|
||||
reqArg.deploymentId,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
deployment: result.deployment,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Scale deployment
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.deployment.IReq_Any_Cloudly_ScaleDeployment>(
|
||||
@@ -254,6 +279,31 @@ export class DeploymentManager {
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const addDeploymentWorkspaceHandler = (methodArg: string, coreflowMethodArg: TCoreflowDeploymentWorkspaceMethod) => {
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<any>(methodArg, async (reqArg) => {
|
||||
await plugins.smartguard.passGuardsOrReject(reqArg, [
|
||||
this.cloudlyRef.authManager.adminIdentityGuard,
|
||||
]);
|
||||
const { identity: _identity, ...payload } = reqArg;
|
||||
const response = await this.cloudlyRef.coreflowManager.fireDeploymentWorkspaceRequest(
|
||||
coreflowMethodArg,
|
||||
payload,
|
||||
);
|
||||
const { found: _found, ...publicResponse } = response;
|
||||
return publicResponse;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
addDeploymentWorkspaceHandler('deploymentWorkspaceReadFile', 'coreflowDeploymentWorkspaceReadFile');
|
||||
addDeploymentWorkspaceHandler('deploymentWorkspaceWriteFile', 'coreflowDeploymentWorkspaceWriteFile');
|
||||
addDeploymentWorkspaceHandler('deploymentWorkspaceReadDir', 'coreflowDeploymentWorkspaceReadDir');
|
||||
addDeploymentWorkspaceHandler('deploymentWorkspaceMkdir', 'coreflowDeploymentWorkspaceMkdir');
|
||||
addDeploymentWorkspaceHandler('deploymentWorkspaceRm', 'coreflowDeploymentWorkspaceRm');
|
||||
addDeploymentWorkspaceHandler('deploymentWorkspaceExists', 'coreflowDeploymentWorkspaceExists');
|
||||
addDeploymentWorkspaceHandler('deploymentWorkspaceExec', 'coreflowDeploymentWorkspaceExec');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
export interface IJumpCodeData {
|
||||
clusterId: string;
|
||||
createdBy: string;
|
||||
role: plugins.servezoneInterfaces.data.IClusterNode['data']['role'];
|
||||
nodeType: plugins.servezoneInterfaces.data.IClusterNode['data']['nodeType'];
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
consumedAt?: number;
|
||||
consumedByNodeId?: string;
|
||||
}
|
||||
|
||||
export interface IJumpCodePublic {
|
||||
id: string;
|
||||
data: IJumpCodeData;
|
||||
}
|
||||
|
||||
@plugins.smartdata.Manager()
|
||||
export class JumpCode extends plugins.smartdata.SmartDataDbDoc<JumpCode, IJumpCodePublic> {
|
||||
constructor(optionsArg?: IJumpCodePublic & { tokenHash?: string }) {
|
||||
super();
|
||||
if (optionsArg) {
|
||||
Object.assign(this, optionsArg);
|
||||
}
|
||||
}
|
||||
|
||||
@plugins.smartdata.unI()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public tokenHash!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public data!: IJumpCodeData;
|
||||
|
||||
public toPublicObject(): IJumpCodePublic {
|
||||
return {
|
||||
id: this.id,
|
||||
data: this.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { Cloudly } from '../classes.cloudly.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { JumpCode } from './classes.jumpcode.js';
|
||||
|
||||
type IReqCreateNodeJumpCommand = plugins.servezoneInterfaces.requests.node.IReq_Any_Cloudly_CreateNodeJumpCommand['request'];
|
||||
type IResCreateNodeJumpCommand = plugins.servezoneInterfaces.requests.node.IReq_Any_Cloudly_CreateNodeJumpCommand['response'];
|
||||
|
||||
interface IClaimJumpCodeRequest {
|
||||
jumpCode?: string;
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
interface IClaimJumpCodeResponse {
|
||||
accepted: boolean;
|
||||
message?: string;
|
||||
nodeId?: string;
|
||||
cloudlyUrl?: string;
|
||||
coreflowJumpCode?: string;
|
||||
}
|
||||
|
||||
export class CloudlyJumpManager {
|
||||
public cloudlyRef: Cloudly;
|
||||
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||
public CJumpCode = plugins.smartdata.setDefaultManagerForDoc(this, JumpCode);
|
||||
|
||||
public get db() {
|
||||
return this.cloudlyRef.mongodbConnector.smartdataDb;
|
||||
}
|
||||
|
||||
private defaultTtlMs = 1000 * 60 * 30;
|
||||
private maxTtlMs = 1000 * 60 * 60 * 24;
|
||||
|
||||
constructor(cloudlyRefArg: Cloudly) {
|
||||
this.cloudlyRef = cloudlyRefArg;
|
||||
this.cloudlyRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.node.IReq_Any_Cloudly_CreateNodeJumpCommand>('createNodeJumpCommand', async (requestDataArg) => {
|
||||
await plugins.smartguard.passGuardsOrReject(
|
||||
{ identity: requestDataArg.identity },
|
||||
[this.cloudlyRef.authManager.adminIdentityGuard],
|
||||
);
|
||||
return await this.createNodeJumpCommand(requestDataArg);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async start() {
|
||||
logger.log('info', 'Jump manager started');
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
logger.log('info', 'Jump manager stopped');
|
||||
}
|
||||
|
||||
public async createNodeJumpCommand(optionsArg: IReqCreateNodeJumpCommand): Promise<IResCreateNodeJumpCommand> {
|
||||
const cluster = await this.cloudlyRef.clusterManager.CCluster.getInstance({
|
||||
id: optionsArg.clusterId,
|
||||
});
|
||||
if (!cluster) {
|
||||
throw new plugins.typedrequest.TypedResponseError(`Cluster ${optionsArg.clusterId} not found`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const ttlMs = this.normalizeTtl(optionsArg.ttlMs);
|
||||
const jumpCode = this.createJumpCode();
|
||||
const jumpCodeDoc = new this.CJumpCode({
|
||||
id: await this.CJumpCode.getNewId(),
|
||||
tokenHash: this.hashSecret(jumpCode),
|
||||
data: {
|
||||
clusterId: cluster.id,
|
||||
createdBy: optionsArg.identity.userId,
|
||||
role: optionsArg.role || 'worker',
|
||||
nodeType: optionsArg.nodeType || 'baremetal',
|
||||
createdAt: now,
|
||||
expiresAt: now + ttlMs,
|
||||
},
|
||||
});
|
||||
await jumpCodeDoc.save();
|
||||
|
||||
const jumpUrl = `${this.getPublicCloudlyUrl()}/jump/${encodeURIComponent(jumpCode)}`;
|
||||
const setupUrl = `${jumpUrl}/setup.sh`;
|
||||
return {
|
||||
jumpCode,
|
||||
jumpUrl,
|
||||
setupUrl,
|
||||
command: `curl -fsSL '${jumpUrl}' | sudo bash`,
|
||||
expiresAt: jumpCodeDoc.data.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
public async handleJumpHttpRequest(ctxArg: plugins.typedserver.IRequestContext): Promise<Response> {
|
||||
const jumpCode = this.getCodeFromContext(ctxArg);
|
||||
if (this.shouldRenderHtml(ctxArg)) {
|
||||
return await this.createLandingPageResponse(jumpCode);
|
||||
}
|
||||
return await this.createSetupScriptResponse(jumpCode);
|
||||
}
|
||||
|
||||
public async handleSetupScriptHttpRequest(ctxArg: plugins.typedserver.IRequestContext): Promise<Response> {
|
||||
return await this.createSetupScriptResponse(this.getCodeFromContext(ctxArg));
|
||||
}
|
||||
|
||||
public async handleClaimHttpRequest(ctxArg: plugins.typedserver.IRequestContext): Promise<Response> {
|
||||
try {
|
||||
const requestData = await this.readJsonBody<IClaimJumpCodeRequest>(ctxArg);
|
||||
const response = await this.claimJumpCode(requestData);
|
||||
return this.createJsonResponse(200, response);
|
||||
} catch (error) {
|
||||
return this.createJsonResponse(400, {
|
||||
accepted: false,
|
||||
message: (error as Error).message,
|
||||
} satisfies IClaimJumpCodeResponse);
|
||||
}
|
||||
}
|
||||
|
||||
public async claimJumpCode(requestDataArg: IClaimJumpCodeRequest): Promise<IClaimJumpCodeResponse> {
|
||||
if (!requestDataArg.jumpCode) {
|
||||
throw new Error('Jump code is missing');
|
||||
}
|
||||
|
||||
const jumpCodeDoc = await this.getJumpCodeByCode(requestDataArg.jumpCode);
|
||||
if (!jumpCodeDoc) {
|
||||
throw new Error('Jump code is invalid');
|
||||
}
|
||||
if (jumpCodeDoc.data.consumedAt) {
|
||||
throw new Error('Jump code has already been used');
|
||||
}
|
||||
if (jumpCodeDoc.data.expiresAt <= Date.now()) {
|
||||
throw new Error('Jump code has expired');
|
||||
}
|
||||
|
||||
const cluster = await this.cloudlyRef.clusterManager.CCluster.getInstance({
|
||||
id: jumpCodeDoc.data.clusterId,
|
||||
});
|
||||
if (!cluster) {
|
||||
throw new Error('Jump code references a missing cluster');
|
||||
}
|
||||
|
||||
const clusterUser = await this.cloudlyRef.authManager.CUser.getInstance({
|
||||
id: cluster.data.userId,
|
||||
});
|
||||
const coreflowJumpCode = clusterUser?.data.tokens?.find((tokenArg) => tokenArg.expiresAt > Date.now())?.token;
|
||||
if (!coreflowJumpCode) {
|
||||
throw new Error('Cluster runtime token is missing or expired');
|
||||
}
|
||||
|
||||
const nodeId = plugins.smartunique.shortId(8);
|
||||
const now = Date.now();
|
||||
const node = new this.cloudlyRef.nodeManager.CClusterNode();
|
||||
node.id = nodeId;
|
||||
node.data = {
|
||||
clusterId: cluster.id,
|
||||
nodeType: jumpCodeDoc.data.nodeType,
|
||||
status: 'initializing',
|
||||
role: jumpCodeDoc.data.role,
|
||||
joinedAt: now,
|
||||
lastHealthCheck: now,
|
||||
sshKeys: [],
|
||||
requiredDebianPackages: [],
|
||||
};
|
||||
await node.save();
|
||||
|
||||
cluster.data.nodes = [
|
||||
...(cluster.data.nodes || []).filter((nodeArg) => nodeArg.id !== node.id),
|
||||
await node.createSavableObject(),
|
||||
];
|
||||
await cluster.save();
|
||||
|
||||
jumpCodeDoc.data = {
|
||||
...jumpCodeDoc.data,
|
||||
consumedAt: now,
|
||||
consumedByNodeId: node.id,
|
||||
};
|
||||
await jumpCodeDoc.save();
|
||||
|
||||
return {
|
||||
accepted: true,
|
||||
nodeId: node.id,
|
||||
cloudlyUrl: cluster.data.cloudlyUrl || `${this.getPublicCloudlyUrl()}/`,
|
||||
coreflowJumpCode,
|
||||
};
|
||||
}
|
||||
|
||||
private async createLandingPageResponse(jumpCodeArg: string) {
|
||||
const jumpCodeDoc = await this.getJumpCodeByCode(jumpCodeArg);
|
||||
let clusterName = 'Unknown cluster';
|
||||
let isUsable = false;
|
||||
if (jumpCodeDoc && !jumpCodeDoc.data.consumedAt && jumpCodeDoc.data.expiresAt > Date.now()) {
|
||||
const cluster = await this.cloudlyRef.clusterManager.CCluster.getInstance({
|
||||
id: jumpCodeDoc.data.clusterId,
|
||||
});
|
||||
clusterName = cluster?.data.name || jumpCodeDoc.data.clusterId;
|
||||
isUsable = true;
|
||||
}
|
||||
const jumpUrl = `${this.getPublicCloudlyUrl()}/jump/${encodeURIComponent(jumpCodeArg)}`;
|
||||
const command = `curl -fsSL '${jumpUrl}' | sudo bash`;
|
||||
const html = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Cloudly Jump</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0d1117; color: #f0f6fc; }
|
||||
main { max-width: 760px; margin: 10vh auto; padding: 32px; }
|
||||
.card { border: 1px solid #30363d; border-radius: 18px; background: #161b22; padding: 28px; box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); }
|
||||
.label { color: #8b949e; font-size: 13px; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
h1 { margin: 8px 0 12px; font-size: 34px; }
|
||||
p { color: #c9d1d9; line-height: 1.55; }
|
||||
pre { white-space: pre-wrap; word-break: break-all; background: #0d1117; border: 1px solid #30363d; border-radius: 12px; padding: 16px; color: #7ee787; }
|
||||
.status { display: inline-block; margin-top: 16px; padding: 6px 10px; border-radius: 999px; background: ${isUsable ? '#17391f' : '#3d1d1d'}; color: ${isUsable ? '#7ee787' : '#ff7b72'}; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="card">
|
||||
<div class="label">Cloudly Jump</div>
|
||||
<h1>Connect System</h1>
|
||||
<p>Cluster: <strong>${this.escapeHtml(clusterName)}</strong></p>
|
||||
<p>Run this command on the Linux system you want to connect:</p>
|
||||
<pre>${this.escapeHtml(command)}</pre>
|
||||
<div class="status">${isUsable ? 'Ready to use' : 'This jump code is invalid, expired, or already used'}</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
return new Response(html, {
|
||||
status: isUsable ? 200 : 404,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async createSetupScriptResponse(jumpCodeArg: string) {
|
||||
if (!jumpCodeArg || !(await this.isJumpCodeUsable(jumpCodeArg))) {
|
||||
return new Response('jump code is invalid, expired, or already used\n', {
|
||||
status: 404,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
},
|
||||
});
|
||||
}
|
||||
return new Response(this.createSetupScript(jumpCodeArg), {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-sh; charset=utf-8',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private createSetupScript(jumpCodeArg: string) {
|
||||
const claimUrl = `${this.getPublicCloudlyUrl()}/jump/v1/claim`;
|
||||
return `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "Cloudly jump setup must run as root. Re-run with sudo." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
export JUMP_CODE='${this.escapeShellValue(jumpCodeArg)}'
|
||||
export CLAIM_URL='${this.escapeShellValue(claimUrl)}'
|
||||
|
||||
echo "Preparing system for Cloudly jump..."
|
||||
apt-get update
|
||||
apt-get install -y --force-yes curl ca-certificates git
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
curl -sSL https://get.docker.com/ | sh
|
||||
fi
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
curl -sL https://deb.nodesource.com/setup_18.x | bash
|
||||
apt-get install -y --force-yes nodejs
|
||||
fi
|
||||
|
||||
if ! command -v pnpm >/dev/null 2>&1; then
|
||||
curl -fsSL https://get.pnpm.io/install.sh | sh -
|
||||
fi
|
||||
|
||||
export PNPM_HOME="\${PNPM_HOME:-/root/.local/share/pnpm}"
|
||||
export PATH="\${PNPM_HOME}:\${PATH}"
|
||||
|
||||
pnpm install -g @serve.zone/spark
|
||||
|
||||
REQUEST_BODY="$(node -e 'process.stdout.write(JSON.stringify({ jumpCode: process.env.JUMP_CODE, hostname: require("os").hostname() }))')"
|
||||
CLAIM_RESPONSE="$(curl -fsSL -X POST "\${CLAIM_URL}" -H 'content-type: application/json' --data "\${REQUEST_BODY}")"
|
||||
|
||||
export CLAIM_RESPONSE
|
||||
CLOUDLY_URL="$(node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.accepted) { throw new Error(data.message || "Cloudly rejected jump code"); } process.stdout.write(data.cloudlyUrl);')"
|
||||
COREFLOW_JUMPCODE="$(node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.coreflowJumpCode) { throw new Error("Cloudly did not return a Coreflow jump code"); } process.stdout.write(data.coreflowJumpCode);')"
|
||||
|
||||
spark installdaemon --mode=coreflow-node --cloudlyUrl="\${CLOUDLY_URL}" --jumpcode="\${COREFLOW_JUMPCODE}"
|
||||
|
||||
echo "Cloudly jump completed. This system is now connected."
|
||||
`;
|
||||
}
|
||||
|
||||
private async getJumpCodeByCode(jumpCodeArg: string) {
|
||||
const jumpCodes = await this.CJumpCode.getInstances({
|
||||
tokenHash: this.hashSecret(jumpCodeArg),
|
||||
});
|
||||
return jumpCodes[0] || null;
|
||||
}
|
||||
|
||||
private async isJumpCodeUsable(jumpCodeArg: string) {
|
||||
const jumpCodeDoc = await this.getJumpCodeByCode(jumpCodeArg);
|
||||
return Boolean(jumpCodeDoc && !jumpCodeDoc.data.consumedAt && jumpCodeDoc.data.expiresAt > Date.now());
|
||||
}
|
||||
|
||||
private getCodeFromContext(ctxArg: plugins.typedserver.IRequestContext) {
|
||||
return ctxArg.params.code || ctxArg.url.pathname.split('/').filter(Boolean)[1] || '';
|
||||
}
|
||||
|
||||
private shouldRenderHtml(ctxArg: plugins.typedserver.IRequestContext) {
|
||||
const acceptHeader = ctxArg.headers.get('accept') || '';
|
||||
const userAgent = ctxArg.headers.get('user-agent') || '';
|
||||
return acceptHeader.includes('text/html') && !/(curl|wget|httpie|fetch)/i.test(userAgent);
|
||||
}
|
||||
|
||||
private createJumpCode() {
|
||||
return plugins.crypto.randomBytes(12).toString('base64url');
|
||||
}
|
||||
|
||||
private normalizeTtl(ttlMsArg?: number) {
|
||||
if (!ttlMsArg || !Number.isFinite(ttlMsArg)) {
|
||||
return this.defaultTtlMs;
|
||||
}
|
||||
return Math.min(Math.max(ttlMsArg, 1000 * 60), this.maxTtlMs);
|
||||
}
|
||||
|
||||
private hashSecret(secretArg: string) {
|
||||
return plugins.crypto.createHash('sha256').update(secretArg).digest('hex');
|
||||
}
|
||||
|
||||
private getPublicCloudlyUrl() {
|
||||
const sslMode = this.cloudlyRef.config.data.sslMode;
|
||||
const protocol = sslMode === 'none' ? 'http' : 'https';
|
||||
const port = String(this.cloudlyRef.config.data.publicPort || (protocol === 'https' ? '443' : '80'));
|
||||
const includePort = !((protocol === 'https' && port === '443') || (protocol === 'http' && port === '80'));
|
||||
return `${protocol}://${this.cloudlyRef.config.data.publicUrl}${includePort ? `:${port}` : ''}`;
|
||||
}
|
||||
|
||||
private async readJsonBody<T>(ctxArg: plugins.typedserver.IRequestContext): Promise<T> {
|
||||
const bodyString = (await ctxArg.text()).trim();
|
||||
return bodyString ? JSON.parse(bodyString) as T : {} as T;
|
||||
}
|
||||
|
||||
private createJsonResponse(statusCodeArg: number, bodyArg: object): Response {
|
||||
return new Response(JSON.stringify(bodyArg), {
|
||||
status: statusCodeArg,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private escapeHtml(valueArg: string) {
|
||||
return valueArg
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
private escapeShellValue(valueArg: string) {
|
||||
return valueArg.replaceAll("'", "'\\''");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/cloudly',
|
||||
version: '5.7.0',
|
||||
version: '5.8.0',
|
||||
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
|
||||
}
|
||||
|
||||
@@ -213,7 +213,6 @@ export class CloudlyDashboard extends DeesElement {
|
||||
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
|
||||
simpleLogin.addEventListener('login', (eventArg: Event) => {
|
||||
const loginEvent = eventArg as CustomEvent;
|
||||
console.log(loginEvent.detail);
|
||||
this.login(loginEvent.detail.data.username, loginEvent.detail.data.password);
|
||||
});
|
||||
|
||||
@@ -244,7 +243,7 @@ export class CloudlyDashboard extends DeesElement {
|
||||
plugins.deesCatalog.DeesContextmenu.openContextMenuWithOptions(eventArg, [
|
||||
{
|
||||
name: 'About',
|
||||
iconName: 'mugHot',
|
||||
iconName: 'lucide:Coffee',
|
||||
action: async () => {
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'About',
|
||||
@@ -267,7 +266,6 @@ export class CloudlyDashboard extends DeesElement {
|
||||
// lets deal with initial state
|
||||
const domtools = await this.domtoolsPromise;
|
||||
const loginState = appstate.loginStatePart.getState();
|
||||
console.log(loginState);
|
||||
if (loginState?.identity) {
|
||||
this.identity = loginState.identity;
|
||||
try {
|
||||
@@ -284,7 +282,6 @@ export class CloudlyDashboard extends DeesElement {
|
||||
|
||||
private async login(username: string, password: string) {
|
||||
const domtools = await this.domtoolsPromise;
|
||||
console.log(`attempting to login...`);
|
||||
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
|
||||
const form = simpleLogin.shadowRoot.querySelector('dees-form') as any;
|
||||
form.setStatus('pending', 'Logging in...');
|
||||
@@ -293,7 +290,6 @@ export class CloudlyDashboard extends DeesElement {
|
||||
password,
|
||||
});
|
||||
if (state?.identity) {
|
||||
console.log('got jwt');
|
||||
this.identity = state.identity;
|
||||
form.setStatus('success', 'Logged in!');
|
||||
await simpleLogin.switchToSlottedContent();
|
||||
|
||||
@@ -23,7 +23,7 @@ const sourcePresetArchitectures: Record<TBaseOsImageSourcePreset, string> = {
|
||||
'balena-raspberrypi4-64': 'rpi',
|
||||
};
|
||||
|
||||
@customElement('cloudly-view-baseos')
|
||||
@customElement('cloudly-view-base-os')
|
||||
export class CloudlyViewBaseOs extends DeesElement {
|
||||
@state() private accessor builds: TBaseOsImageBuild[] = [];
|
||||
@state() private accessor isLoading = false;
|
||||
@@ -300,6 +300,6 @@ export class CloudlyViewBaseOs extends DeesElement {
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'cloudly-view-baseos': CloudlyViewBaseOs;
|
||||
'cloudly-view-base-os': CloudlyViewBaseOs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
|
||||
import * as appstate from '../../../appstate.js';
|
||||
|
||||
@customElement('cloudly-view-externalregistries')
|
||||
@customElement('cloudly-view-external-registries')
|
||||
export class CloudlyViewExternalRegistries extends DeesElement {
|
||||
@state()
|
||||
private accessor data: appstate.IDataState = { secretGroups: [], secretBundles: [], externalRegistries: [] } as any;
|
||||
@@ -114,4 +114,4 @@ export class CloudlyViewExternalRegistries extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
declare global { interface HTMLElementTagNameMap { 'cloudly-view-externalregistries': CloudlyViewExternalRegistries; } }
|
||||
declare global { interface HTMLElementTagNameMap { 'cloudly-view-external-registries': CloudlyViewExternalRegistries; } }
|
||||
|
||||
@@ -64,7 +64,7 @@ export class CloudlyViewImages extends DeesElement {
|
||||
{
|
||||
name: 'edit',
|
||||
type: ['contextmenu', 'inRow', 'doubleClick'],
|
||||
iconName: 'penToSquare',
|
||||
iconName: 'lucide:SquarePen',
|
||||
actionFunc: async (dataArg: plugins.deesCatalog.ITableActionDataArg<plugins.interfaces.data.ISecretGroup>) => {
|
||||
const environmentsArray: Array<plugins.interfaces.data.ISecretGroup['data']['environments'][any] & { environment: string; }> = [];
|
||||
for (const environmentName of Object.keys(dataArg.item.data.environments)) {
|
||||
@@ -94,7 +94,7 @@ export class CloudlyViewImages extends DeesElement {
|
||||
},
|
||||
{
|
||||
name: 'history',
|
||||
iconName: 'clockRotateLeft',
|
||||
iconName: 'lucide:History',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (dataArg: plugins.deesCatalog.ITableActionDataArg<plugins.interfaces.data.ISecretGroup>) => {
|
||||
const historyArray: Array<{ environment: string; value: string; }> = [];
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
|
||||
import * as appstate from '../../../appstate.js';
|
||||
|
||||
@customElement('cloudly-view-secretbundles')
|
||||
@customElement('cloudly-view-secret-bundles')
|
||||
export class CloudlyViewSecretBundles extends DeesElement {
|
||||
@state()
|
||||
private accessor data: appstate.IDataState = {} as any;
|
||||
@@ -63,7 +63,7 @@ export class CloudlyViewSecretBundles extends DeesElement {
|
||||
<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>
|
||||
`, menuOptions: [ { name: 'cancel', action: async (modalArg: any) => { await modalArg.destroy(); } }, { name: 'delete', action: async (modalArg: any) => { appstate.dataState.dispatchAction(appstate.deleteSecretBundleAction, { configBundleId: actionDataArg.item.id, }); await modalArg.destroy(); } } ] });
|
||||
} },
|
||||
{ name: 'edit', iconName: 'penToSquare', type: ['doubleClick', 'contextmenu', 'inRow'], actionFunc: async () => {
|
||||
{ name: 'edit', iconName: 'lucide:SquarePen', type: ['doubleClick', 'contextmenu', 'inRow'], actionFunc: async () => {
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({ heading: 'Edit SecretBundle', content: html`<dees-form><dees-input-text .label=${'purpose'}></dees-input-text></dees-form>`, menuOptions: [ { name: 'save', action: async (modalArg: any) => {} }, { name: 'cancel', action: async (modalArg: any) => { modalArg.destroy(); } } ] });
|
||||
} },
|
||||
] as plugins.deesCatalog.ITableAction[]}
|
||||
@@ -72,4 +72,4 @@ export class CloudlyViewSecretBundles extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
declare global { interface HTMLElementTagNameMap { 'cloudly-view-secretbundles': CloudlyViewSecretBundles; } }
|
||||
declare global { interface HTMLElementTagNameMap { 'cloudly-view-secret-bundles': CloudlyViewSecretBundles; } }
|
||||
|
||||
@@ -5,7 +5,7 @@ import { DeesElement, customElement, html, state, css, cssManager } from '@desig
|
||||
|
||||
import * as appstate from '../../../appstate.js';
|
||||
|
||||
@customElement('cloudly-view-secretsgroups')
|
||||
@customElement('cloudly-view-secret-groups')
|
||||
export class CloudlyViewSecretGroups extends DeesElement {
|
||||
@state()
|
||||
private accessor data: appstate.IDataState = {} as any;
|
||||
@@ -46,7 +46,7 @@ export class CloudlyViewSecretGroups extends DeesElement {
|
||||
</dees-form>
|
||||
`, menuOptions: [ { name: 'cancel', action: async (modalArg: any) => { await modalArg.destroy(); } }, { name: 'save', action: async (modalArg: any) => { const deesForm = modalArg.shadowRoot.querySelector('dees-form'); const formData = await deesForm.collectFormData(); const environments: plugins.interfaces.data.ISecretGroup['data']['environments'] = {}; for (const itemArg of formData['environments'] as any[]) { environments[itemArg.environment] = { value: itemArg.value, history: [], lastUpdated: Date.now(), }; } await appstate.dataState.dispatchAction(appstate.createSecretGroupAction, { data: { name: formData['data.name'] as string, description: formData['data.description'] as string, key: formData['data.key'] as string, environments, tags: [], }, }); await modalArg.destroy(); } } ] });
|
||||
} },
|
||||
{ name: 'edit', type: ['contextmenu', 'inRow', 'doubleClick'], iconName: 'penToSquare', actionFunc: async (dataArg: plugins.deesCatalog.ITableActionDataArg<plugins.interfaces.data.ISecretGroup>) => {
|
||||
{ name: 'edit', type: ['contextmenu', 'inRow', 'doubleClick'], iconName: 'lucide:SquarePen', actionFunc: async (dataArg: plugins.deesCatalog.ITableActionDataArg<plugins.interfaces.data.ISecretGroup>) => {
|
||||
const environmentsArray: Array<plugins.interfaces.data.ISecretGroup['data']['environments'][any] & { environment: string; }> = [];
|
||||
for (const environmentName of Object.keys(dataArg.item.data.environments)) { environmentsArray.push({ environment: environmentName, ...dataArg.item.data.environments[environmentName], }); }
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({ heading: 'Edit Secret', content: html`
|
||||
@@ -60,7 +60,7 @@ export class CloudlyViewSecretGroups extends DeesElement {
|
||||
</dees-form>
|
||||
`, menuOptions: [ { name: 'Cancel', iconName: undefined, action: async (modalArg: any) => { await modalArg.destroy(); } }, { name: 'Save', iconName: undefined, action: async (modalArg: any) => { const data = await modalArg.shadowRoot.querySelector('dees-form').collectFormData(); console.log(data); } } ] });
|
||||
} },
|
||||
{ name: 'history', iconName: 'clockRotateLeft', type: ['contextmenu', 'inRow'], actionFunc: async (dataArg: plugins.deesCatalog.ITableActionDataArg<plugins.interfaces.data.ISecretGroup>) => {
|
||||
{ name: 'history', iconName: 'lucide:History', type: ['contextmenu', 'inRow'], actionFunc: async (dataArg: plugins.deesCatalog.ITableActionDataArg<plugins.interfaces.data.ISecretGroup>) => {
|
||||
const historyArray: Array<{ environment: string; value: string; }> = []; for (const environment of Object.keys(dataArg.item.data.environments)) { for (const historyItem of dataArg.item.data.environments[environment].history) { historyArray.push({ environment, value: historyItem.value, }); } }
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({ heading: `history for ${dataArg.item.data.key}`, content: html`<dees-table .data=${historyArray} .dataActions=${[{ name: 'delete', iconName: 'trash', type: ['contextmenu', 'inRow'], actionFunc: async (itemArg: plugins.deesCatalog.ITableActionDataArg<(typeof historyArray)[0]>) => { console.log('delete', itemArg); }, }] as plugins.deesCatalog.ITableAction[]}></dees-table>`, menuOptions: [ { name: 'close', action: async (modalArg: any) => { await modalArg.destroy(); } } ] });
|
||||
} },
|
||||
@@ -73,4 +73,4 @@ export class CloudlyViewSecretGroups extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
declare global { interface HTMLElementTagNameMap { 'cloudly-view-secretsgroups': CloudlyViewSecretGroups; } }
|
||||
declare global { interface HTMLElementTagNameMap { 'cloudly-view-secret-groups': CloudlyViewSecretGroups; } }
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user