Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bac0a5f71 | |||
| 26256c92bd | |||
| c7a307c9d3 | |||
| 06d54db747 | |||
| 756c35aa05 | |||
| 2adb86c5ea |
@@ -3,6 +3,47 @@
|
||||
## Pending
|
||||
|
||||
|
||||
## 2026-05-26 - 6.4.0
|
||||
|
||||
### Features
|
||||
|
||||
- add hosted Cloudly parent upgrade controls (hostedapp)
|
||||
- Proxy admin upgrade status and start requests to the parent hosted-app runtime with the service-scoped runtime identity.
|
||||
- Add a Settings hosted runtime panel for status refresh, parent upgrade start, and running-upgrade polling.
|
||||
- Update `@serve.zone/interfaces` to `^6.2.0` for the parent upgrade contracts.
|
||||
- add hosted Cloudly parent upgrade controls (hostedapp)
|
||||
- Proxy admin upgrade status and start requests to the parent hosted-app runtime using the service runtime identity.
|
||||
- Add Settings hosted runtime status, refresh, upgrade start, and running-upgrade polling UI.
|
||||
- Update @serve.zone/interfaces to ^6.2.0 for parent upgrade request contracts.
|
||||
|
||||
## 2026-05-26 - 6.3.1
|
||||
|
||||
- remove redundant card wrappers around Cloudly tables (ui)
|
||||
- Lets `dees-table` provide its own card shell in service, image, and task history views.
|
||||
- Moves the live deployment refresh action into the table header actions.
|
||||
|
||||
### Fixes
|
||||
|
||||
- remove redundant wrappers around Cloudly tables (ui)
|
||||
- Let dees-table provide its own card shell in service, image, and task history views.
|
||||
- Move the live deployments refresh action into the deployments table header actions.
|
||||
- Bump @types/node to ^25.9.1.
|
||||
|
||||
## 2026-05-26 - 6.3.0
|
||||
|
||||
- add hosted app lifecycle protocol support (hostedapp)
|
||||
- Implements generic Hosted App TypedRequest handlers for Cloudly-hosted App Store services.
|
||||
- Injects service-scoped runtime identity environment variables into Cloudly App Store installs.
|
||||
- Lets Cloudly report initial admin bootstrap credentials to its parent host when `SERVEZONE_ADMINACCOUNT` is not configured.
|
||||
|
||||
### Features
|
||||
|
||||
- add hosted app lifecycle protocol support (hostedapp)
|
||||
- Adds a hosted app manager with lifecycle, bootstrap, and managed upgrade TypedRequest handlers.
|
||||
- Injects hosted app runtime identity environment variables into App Store installs.
|
||||
- Allows initial admin bootstrap credentials to be requested from the parent hosted app runtime when SERVEZONE_ADMINACCOUNT is not configured.
|
||||
- Updates hosted app platform requirements and @serve.zone/interfaces for the lifecycle protocol.
|
||||
|
||||
## 2026-05-26 - 6.2.0
|
||||
|
||||
### Features
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/cloudly",
|
||||
"version": "6.2.0",
|
||||
"version": "6.4.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",
|
||||
@@ -31,7 +31,7 @@
|
||||
"@git.zone/tstest": "^3.6.6",
|
||||
"@git.zone/tswatch": "^3.3.5",
|
||||
"@push.rocks/smartnetwork": "^4.7.1",
|
||||
"@types/node": "^25.9.0"
|
||||
"@types/node": "^25.9.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "3.3.1",
|
||||
@@ -80,7 +80,7 @@
|
||||
"@push.rocks/webjwt": "^1.0.10",
|
||||
"@serve.zone/api": "^5.3.8",
|
||||
"@serve.zone/appstore": "^0.2.0",
|
||||
"@serve.zone/interfaces": "^6.0.1",
|
||||
"@serve.zone/interfaces": "^6.2.0",
|
||||
"@tsclass/tsclass": "^9.5.1"
|
||||
},
|
||||
"files": [
|
||||
|
||||
Generated
+27
-27
@@ -147,8 +147,8 @@ importers:
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
'@serve.zone/interfaces':
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1
|
||||
specifier: ^6.2.0
|
||||
version: 6.2.0
|
||||
'@tsclass/tsclass':
|
||||
specifier: ^9.5.1
|
||||
version: 9.5.1
|
||||
@@ -178,8 +178,8 @@ importers:
|
||||
specifier: ^4.7.1
|
||||
version: 4.7.1
|
||||
'@types/node':
|
||||
specifier: ^25.9.0
|
||||
version: 25.9.0
|
||||
specifier: ^25.9.1
|
||||
version: 25.9.1
|
||||
|
||||
packages:
|
||||
|
||||
@@ -1838,8 +1838,8 @@ packages:
|
||||
'@serve.zone/interfaces@5.10.0':
|
||||
resolution: {integrity: sha512-8ZnP1A43UZlYwfd2j+S0Yin//didacIX2Rou9MobRuSFFgi1RQOqQcIWqOINcDk80wBDuYkyMCwHygYxD5i+Ig==}
|
||||
|
||||
'@serve.zone/interfaces@6.0.1':
|
||||
resolution: {integrity: sha512-ZeLi0Bge8qRMoZMN5/xQ/8VRI4ep9ImitpZtNuLmeNHu0pGICcBGQE4g1aMmi+E3JynKOAphH4dnVmRULZV/RA==}
|
||||
'@serve.zone/interfaces@6.2.0':
|
||||
resolution: {integrity: sha512-7eZIdl0IcuiUReGetJnOFkewCWBTEVGJSyUHdQkjtr0FLfgyqgm4ItlJlWPVpFlapm6GxkHYmPBkwxrpOq1Bsw==}
|
||||
|
||||
'@shikijs/engine-oniguruma@3.23.0':
|
||||
resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
|
||||
@@ -2308,11 +2308,11 @@ packages:
|
||||
'@types/node@18.19.130':
|
||||
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
|
||||
|
||||
'@types/node@22.19.18':
|
||||
resolution: {integrity: sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==}
|
||||
'@types/node@22.19.19':
|
||||
resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==}
|
||||
|
||||
'@types/node@25.9.0':
|
||||
resolution: {integrity: sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==}
|
||||
'@types/node@25.9.1':
|
||||
resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==}
|
||||
|
||||
'@types/randomatic@3.1.5':
|
||||
resolution: {integrity: sha512-VCwCTw6qh1pRRw+5rNTAwqPmf6A+hdrkdM7dBpZVmhl7g+em3ONXlYK/bWPVKqVGMWgP0d1bog8Vc/X6zRwRRQ==}
|
||||
@@ -5735,7 +5735,7 @@ snapshots:
|
||||
|
||||
'@happy-dom/global-registrator@20.9.0':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
happy-dom: 20.9.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
@@ -5855,7 +5855,7 @@ snapshots:
|
||||
'@inquirer/figures': 1.0.15
|
||||
'@inquirer/type': 2.0.0
|
||||
'@types/mute-stream': 0.0.4
|
||||
'@types/node': 22.19.18
|
||||
'@types/node': 22.19.19
|
||||
'@types/wrap-ansi': 3.0.0
|
||||
ansi-escapes: 4.3.2
|
||||
cli-width: 4.1.0
|
||||
@@ -7364,7 +7364,7 @@ snapshots:
|
||||
|
||||
'@serve.zone/appstore@0.2.0':
|
||||
dependencies:
|
||||
'@serve.zone/interfaces': 6.0.1
|
||||
'@serve.zone/interfaces': 6.2.0
|
||||
|
||||
'@serve.zone/interfaces@5.10.0':
|
||||
dependencies:
|
||||
@@ -7372,7 +7372,7 @@ snapshots:
|
||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||
'@tsclass/tsclass': 9.5.1
|
||||
|
||||
'@serve.zone/interfaces@6.0.1':
|
||||
'@serve.zone/interfaces@6.2.0':
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||
@@ -7915,7 +7915,7 @@ snapshots:
|
||||
|
||||
'@types/clean-css@4.2.11':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
source-map: 0.6.1
|
||||
|
||||
'@types/debug@4.1.13':
|
||||
@@ -7931,7 +7931,7 @@ snapshots:
|
||||
'@types/fs-extra@11.0.4':
|
||||
dependencies:
|
||||
'@types/jsonfile': 6.1.4
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
dependencies:
|
||||
@@ -7947,12 +7947,12 @@ snapshots:
|
||||
|
||||
'@types/jsonfile@6.1.4':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
|
||||
@@ -7973,16 +7973,16 @@ snapshots:
|
||||
|
||||
'@types/mute-stream@0.0.4':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
|
||||
'@types/node-fetch@2.6.13':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
form-data: 4.0.5
|
||||
|
||||
'@types/node-forge@1.3.14':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
|
||||
'@types/node@16.9.1': {}
|
||||
|
||||
@@ -7990,11 +7990,11 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 5.26.5
|
||||
|
||||
'@types/node@22.19.18':
|
||||
'@types/node@22.19.19':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@25.9.0':
|
||||
'@types/node@25.9.1':
|
||||
dependencies:
|
||||
undici-types: 7.24.6
|
||||
|
||||
@@ -8012,7 +8012,7 @@ snapshots:
|
||||
|
||||
'@types/through2@2.0.41':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
@@ -8040,11 +8040,11 @@ snapshots:
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
optional: true
|
||||
|
||||
'@ungap/structured-clone@1.3.1': {}
|
||||
@@ -8780,7 +8780,7 @@ snapshots:
|
||||
|
||||
happy-dom@20.9.0:
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
'@types/whatwg-mimetype': 3.0.2
|
||||
'@types/ws': 8.18.1
|
||||
entities: 7.0.1
|
||||
|
||||
@@ -47,12 +47,6 @@
|
||||
"description": "Use external TLS termination through Onebox or dcrouter.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "SERVEZONE_ADMINACCOUNT",
|
||||
"value": "",
|
||||
"description": "Initial admin account in username:password format. Only used when Cloudly has no human users yet.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "MONGODB_URL",
|
||||
"value": "${MONGODB_URI}",
|
||||
@@ -118,7 +112,7 @@
|
||||
"mongodb": true,
|
||||
"s3": true
|
||||
},
|
||||
"minOneboxVersion": "1.24.2",
|
||||
"minOneboxVersion": "2.2.0",
|
||||
"backupBeforeUpgrade": true,
|
||||
"healthCheck": {
|
||||
"path": "/status",
|
||||
|
||||
@@ -16,6 +16,28 @@ const logErrorDetails = (errorArg: unknown) => {
|
||||
console.error(` - Error:`, errorArg);
|
||||
};
|
||||
|
||||
const withParentRuntimeEnvCleared = async <T>(callbackArg: () => Promise<T>): Promise<T> => {
|
||||
const previousEnv = {
|
||||
SERVEZONE_RUNTIME_URL: process.env.SERVEZONE_RUNTIME_URL,
|
||||
SERVEZONE_APP_INSTANCE_ID: process.env.SERVEZONE_APP_INSTANCE_ID,
|
||||
SERVEZONE_APP_CONTROL_TOKEN: process.env.SERVEZONE_APP_CONTROL_TOKEN,
|
||||
};
|
||||
delete process.env.SERVEZONE_RUNTIME_URL;
|
||||
delete process.env.SERVEZONE_APP_INSTANCE_ID;
|
||||
delete process.env.SERVEZONE_APP_CONTROL_TOKEN;
|
||||
try {
|
||||
return await callbackArg();
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(previousEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
tap.preTask('should start cloudly', async () => {
|
||||
testCloudly = await helpers.createCloudly();
|
||||
await testCloudly.start();
|
||||
@@ -92,6 +114,24 @@ tap.test('should get an identity', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should report parent hosted upgrade unavailable when not hosted', async () => {
|
||||
await withParentRuntimeEnvCleared(async () => {
|
||||
const statusRequest = testClient.typedsocketClient.createTypedRequest<any>('getHostedAppParentUpgradeStatus');
|
||||
const statusResponse = await statusRequest.fire({ identity: testClient.identity });
|
||||
expect(statusResponse.isHosted).toBeFalse();
|
||||
expect(statusResponse.unavailableReason).toEqual('SERVEZONE_RUNTIME_URL is not configured.');
|
||||
expect(statusResponse.upgradeState.status).toEqual('unknown');
|
||||
|
||||
const startRequest = testClient.typedsocketClient.createTypedRequest<any>('startHostedAppParentUpgrade');
|
||||
const startResponse = await startRequest.fire({
|
||||
identity: testClient.identity,
|
||||
targetVersion: '0.0.0-test',
|
||||
});
|
||||
expect(startResponse.isHosted).toBeFalse();
|
||||
expect(startResponse.upgradeState.status).toEqual('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/cloudly',
|
||||
version: '6.2.0',
|
||||
version: '6.4.0',
|
||||
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { CloudlyPlatformManager } from './manager.platform/classes.platformmanag
|
||||
import { CloudlyBackupManager } from './manager.backup/classes.backupmanager.js';
|
||||
import { CloudlyBaseOsManager } from './manager.baseos/classes.baseosmanager.js';
|
||||
import { CloudlyAppStoreManager } from './manager.appstore/classes.appstoremanager.js';
|
||||
import { CloudlyHostedAppManager } from './manager.hostedapp/classes.hostedappmanager.js';
|
||||
import { CloudlyJumpManager } from './manager.jump/classes.jumpmanager.js';
|
||||
|
||||
/**
|
||||
@@ -82,6 +83,7 @@ export class Cloudly {
|
||||
public baremetalManager: CloudlyBaremetalManager;
|
||||
public baseOsManager: CloudlyBaseOsManager;
|
||||
public appStoreManager: CloudlyAppStoreManager;
|
||||
public hostedAppManager: CloudlyHostedAppManager;
|
||||
public jumpManager: CloudlyJumpManager;
|
||||
|
||||
private readyDeferred = new plugins.smartpromise.Deferred();
|
||||
@@ -119,6 +121,7 @@ export class Cloudly {
|
||||
this.backupManager = new CloudlyBackupManager(this);
|
||||
this.baseOsManager = new CloudlyBaseOsManager(this);
|
||||
this.secretManager = new CloudlySecretManager(this);
|
||||
this.hostedAppManager = new CloudlyHostedAppManager(this);
|
||||
this.appStoreManager = new CloudlyAppStoreManager(this);
|
||||
this.nodeManager = new CloudlyNodeManager(this);
|
||||
this.baremetalManager = new CloudlyBaremetalManager(this);
|
||||
@@ -151,6 +154,7 @@ export class Cloudly {
|
||||
await this.taskManager.init();
|
||||
await this.backupManager.start();
|
||||
await this.baseOsManager.start();
|
||||
await this.hostedAppManager.start();
|
||||
await this.appStoreManager.start();
|
||||
await this.registryManager.start();
|
||||
await this.domainManager.init();
|
||||
@@ -186,6 +190,7 @@ export class Cloudly {
|
||||
await this.backupManager.stop();
|
||||
await this.baseOsManager.stop();
|
||||
await this.registryManager.stop();
|
||||
await this.hostedAppManager.stop();
|
||||
await this.appStoreManager.stop();
|
||||
await this.externalRegistryManager.stop();
|
||||
}
|
||||
|
||||
@@ -240,6 +240,12 @@ export class CloudlyAppStoreManager {
|
||||
.slice(0, 25);
|
||||
}
|
||||
|
||||
public async startHostedAppUpgrade(serviceIdArg: string, targetVersionArg: string): Promise<IAppStoreUpgradeOperation> {
|
||||
const operation = await this.createUpgradeOperation(serviceIdArg, targetVersionArg);
|
||||
void this.performUpgrade(operation.id).catch(() => {});
|
||||
return operation;
|
||||
}
|
||||
|
||||
public async getAppStoreUpgradePreview(
|
||||
serviceIdArg: string,
|
||||
targetVersionArg?: string,
|
||||
@@ -545,7 +551,11 @@ export class CloudlyAppStoreManager {
|
||||
this.assertRuntimeCompatibility(config);
|
||||
this.assertSupportedPlatformRequirements(config);
|
||||
this.assertSupportedPublishedPorts(publishedPorts);
|
||||
const envVars = this.getAppStoreEnvVars(config, optionsArg.envVars || {});
|
||||
const hostedAppRuntime = this.cloudlyRef.hostedAppManager.createHostedAppRuntimeEnvVars(optionsArg.serviceName);
|
||||
const envVars = {
|
||||
...this.getAppStoreEnvVars(config, optionsArg.envVars || {}),
|
||||
...hostedAppRuntime.envVars,
|
||||
};
|
||||
if (this.requiresTemplateValue(envVars, 'SERVICE_DOMAIN') && !optionsArg.domain) {
|
||||
throw new Error('A domain is required because the app template uses ${SERVICE_DOMAIN}');
|
||||
}
|
||||
@@ -567,6 +577,7 @@ export class CloudlyAppStoreManager {
|
||||
appTemplateId: optionsArg.appId,
|
||||
appTemplateVersion: appStoreVersion,
|
||||
appStoreUpgradePolicy: 'manual',
|
||||
hostedAppLifecycle: hostedAppRuntime.lifecycle,
|
||||
environment: envVars,
|
||||
secretBundleId: secretBundle.id,
|
||||
additionalSecretBundleIds: [],
|
||||
|
||||
@@ -113,19 +113,28 @@ export class CloudlyAuthManager {
|
||||
}
|
||||
|
||||
const adminAccount = this.cloudlyRef.config.data.servezoneAdminaccount;
|
||||
if (!adminAccount) {
|
||||
throw new Error('SERVEZONE_ADMINACCOUNT is required for first-run Cloudly bootstrap');
|
||||
}
|
||||
let username: string;
|
||||
let password: string;
|
||||
let hostedBootstrapActionId: string | undefined;
|
||||
if (adminAccount) {
|
||||
const separatorIndex = adminAccount.indexOf(':');
|
||||
if (separatorIndex <= 0 || separatorIndex === adminAccount.length - 1) {
|
||||
throw new Error('SERVEZONE_ADMINACCOUNT must use username:password format');
|
||||
}
|
||||
|
||||
const separatorIndex = adminAccount.indexOf(':');
|
||||
if (separatorIndex <= 0 || separatorIndex === adminAccount.length - 1) {
|
||||
throw new Error('SERVEZONE_ADMINACCOUNT must use username:password format');
|
||||
}
|
||||
|
||||
const username = adminAccount.slice(0, separatorIndex).trim();
|
||||
const password = adminAccount.slice(separatorIndex + 1);
|
||||
if (!username || !password) {
|
||||
throw new Error('SERVEZONE_ADMINACCOUNT must include a non-empty username and password');
|
||||
username = adminAccount.slice(0, separatorIndex).trim();
|
||||
password = adminAccount.slice(separatorIndex + 1);
|
||||
if (!username || !password) {
|
||||
throw new Error('SERVEZONE_ADMINACCOUNT must include a non-empty username and password');
|
||||
}
|
||||
} else {
|
||||
const hostedBootstrap = await this.cloudlyRef.hostedAppManager.requestParentInitialAdminBootstrap();
|
||||
if (!hostedBootstrap) {
|
||||
throw new Error('SERVEZONE_ADMINACCOUNT is required for first-run Cloudly bootstrap unless hosted app lifecycle credentials are available');
|
||||
}
|
||||
username = hostedBootstrap.username;
|
||||
password = hostedBootstrap.password;
|
||||
hostedBootstrapActionId = hostedBootstrap.actionId;
|
||||
}
|
||||
|
||||
const user = new this.CUser({
|
||||
@@ -139,6 +148,14 @@ export class CloudlyAuthManager {
|
||||
});
|
||||
await user.save();
|
||||
logger.log('success', `created initial admin user ${username}`);
|
||||
if (hostedBootstrapActionId) {
|
||||
await this.cloudlyRef.hostedAppManager.completeParentBootstrapAction(
|
||||
hostedBootstrapActionId,
|
||||
'Cloudly created the initial admin user.',
|
||||
).catch((errorArg) => {
|
||||
logger.log('warn', `failed to complete hosted app bootstrap action: ${(errorArg as Error).message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async stop() {}
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import type { Cloudly } from '../classes.cloudly.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Service } from '../manager.service/classes.service.js';
|
||||
|
||||
type IHostedAppLifecycleState = plugins.servezoneInterfaces.data.IHostedAppLifecycleState;
|
||||
type IHostedAppUpgradeState = plugins.servezoneInterfaces.data.IHostedAppUpgradeState;
|
||||
type IHostedAppRuntimeIdentity = plugins.servezoneInterfaces.data.IHostedAppRuntimeIdentity;
|
||||
|
||||
interface IHostedAppParentUpgradeResponse {
|
||||
isHosted: boolean;
|
||||
unavailableReason?: string;
|
||||
upgradeState: IHostedAppUpgradeState;
|
||||
}
|
||||
|
||||
type TExtendedServiceData = plugins.servezoneInterfaces.data.IService['data'] & {
|
||||
hostedAppLifecycle?: IHostedAppLifecycleState;
|
||||
};
|
||||
|
||||
export class CloudlyHostedAppManager {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private cloudlyRef: Cloudly) {
|
||||
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
public async start() {}
|
||||
public async stop() {}
|
||||
|
||||
private getParentRuntimeIdentity(): IHostedAppRuntimeIdentity | null {
|
||||
const appInstanceId = process.env.SERVEZONE_APP_INSTANCE_ID;
|
||||
const appControlToken = process.env.SERVEZONE_APP_CONTROL_TOKEN;
|
||||
if (!appInstanceId || !appControlToken) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
appInstanceId,
|
||||
appControlToken,
|
||||
hostType: process.env.SERVEZONE_APP_HOST_TYPE || 'onebox',
|
||||
};
|
||||
}
|
||||
|
||||
private createParentRuntimeTypedRequest<TRequest extends plugins.typedrequestInterfaces.ITypedRequest>(methodArg: TRequest['method']): plugins.typedrequest.TypedRequest<TRequest> | null {
|
||||
const runtimeUrl = process.env.SERVEZONE_RUNTIME_URL;
|
||||
if (!runtimeUrl) {
|
||||
return null;
|
||||
}
|
||||
return new plugins.typedrequest.TypedRequest<TRequest>(
|
||||
`${runtimeUrl.replace(/\/+$/, '')}/typedrequest`,
|
||||
methodArg,
|
||||
);
|
||||
}
|
||||
|
||||
private getParentRuntimeUnavailableReason(): string | undefined {
|
||||
if (!process.env.SERVEZONE_RUNTIME_URL) {
|
||||
return 'SERVEZONE_RUNTIME_URL is not configured.';
|
||||
}
|
||||
if (!process.env.SERVEZONE_APP_INSTANCE_ID || !process.env.SERVEZONE_APP_CONTROL_TOKEN) {
|
||||
return 'Hosted app runtime identity is not configured.';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private getErrorMessage(errorArg: unknown): string {
|
||||
return errorArg instanceof Error ? errorArg.message : String(errorArg);
|
||||
}
|
||||
|
||||
public async getParentUpgradeStatus(): Promise<IHostedAppParentUpgradeResponse> {
|
||||
const unavailableReason = this.getParentRuntimeUnavailableReason();
|
||||
const identity = this.getParentRuntimeIdentity();
|
||||
const request = this.createParentRuntimeTypedRequest<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_GetManagedUpgradeStatus>(
|
||||
'hostedAppGetManagedUpgradeStatus',
|
||||
);
|
||||
if (unavailableReason || !identity || !request) {
|
||||
return {
|
||||
isHosted: false,
|
||||
unavailableReason,
|
||||
upgradeState: { status: 'unknown' },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request.fire({ identity });
|
||||
return {
|
||||
isHosted: true,
|
||||
upgradeState: response.upgradeState,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = this.getErrorMessage(error);
|
||||
return {
|
||||
isHosted: true,
|
||||
unavailableReason: message,
|
||||
upgradeState: {
|
||||
status: 'unknown',
|
||||
error: message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async startParentUpgrade(targetVersionArg?: string): Promise<IHostedAppParentUpgradeResponse> {
|
||||
const unavailableReason = this.getParentRuntimeUnavailableReason();
|
||||
const identity = this.getParentRuntimeIdentity();
|
||||
const request = this.createParentRuntimeTypedRequest<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_StartManagedUpgrade>(
|
||||
'hostedAppStartManagedUpgrade',
|
||||
);
|
||||
if (unavailableReason || !identity || !request) {
|
||||
return {
|
||||
isHosted: false,
|
||||
unavailableReason,
|
||||
upgradeState: { status: 'unknown' },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request.fire({
|
||||
identity,
|
||||
targetVersion: targetVersionArg,
|
||||
});
|
||||
return {
|
||||
isHosted: true,
|
||||
upgradeState: response.upgradeState,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = this.getErrorMessage(error);
|
||||
return {
|
||||
isHosted: true,
|
||||
unavailableReason: message,
|
||||
upgradeState: {
|
||||
status: 'failed',
|
||||
error: message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async requestParentInitialAdminBootstrap(): Promise<{
|
||||
username: string;
|
||||
password: string;
|
||||
actionId: string;
|
||||
} | null> {
|
||||
const identity = this.getParentRuntimeIdentity();
|
||||
const request = this.createParentRuntimeTypedRequest<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_RequestBootstrapAction>(
|
||||
'hostedAppRequestBootstrapAction',
|
||||
);
|
||||
if (!identity || !request) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const username = 'admin';
|
||||
const password = plugins.smartunique.uniSimple('cloudlyadmin', 32);
|
||||
const response = await request.fire({
|
||||
identity,
|
||||
action: {
|
||||
type: 'credentials',
|
||||
label: 'Cloudly initial admin',
|
||||
url: `https://${this.cloudlyRef.config.data.publicUrl}`,
|
||||
username,
|
||||
password,
|
||||
message: 'Use these credentials to sign in to Cloudly, then change the admin password.',
|
||||
},
|
||||
});
|
||||
return {
|
||||
username,
|
||||
password,
|
||||
actionId: response.action.id,
|
||||
};
|
||||
}
|
||||
|
||||
public async completeParentBootstrapAction(actionIdArg?: string, messageArg?: string): Promise<void> {
|
||||
const identity = this.getParentRuntimeIdentity();
|
||||
const request = this.createParentRuntimeTypedRequest<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_CompleteBootstrapAction>(
|
||||
'hostedAppCompleteBootstrapAction',
|
||||
);
|
||||
if (!identity || !request) {
|
||||
return;
|
||||
}
|
||||
await request.fire({
|
||||
identity,
|
||||
actionId: actionIdArg,
|
||||
message: messageArg,
|
||||
});
|
||||
}
|
||||
|
||||
public createHostedAppRuntimeEnvVars(serviceNameArg: string): {
|
||||
appInstanceId: string;
|
||||
appControlToken: string;
|
||||
envVars: Record<string, string>;
|
||||
lifecycle: IHostedAppLifecycleState;
|
||||
} {
|
||||
const appInstanceId = plugins.smartunique.uniSimple('hostedapp');
|
||||
const appControlToken = plugins.smartunique.uniSimple('hostedapptoken', 64);
|
||||
const runtimeUrl = `https://${this.cloudlyRef.config.data.publicUrl}`;
|
||||
return {
|
||||
appInstanceId,
|
||||
appControlToken,
|
||||
envVars: {
|
||||
SERVEZONE_RUNTIME_URL: runtimeUrl,
|
||||
SERVEZONE_APP_INSTANCE_ID: appInstanceId,
|
||||
SERVEZONE_APP_CONTROL_TOKEN: appControlToken,
|
||||
SERVEZONE_APP_HOST_TYPE: 'cloudly',
|
||||
},
|
||||
lifecycle: {
|
||||
appInstanceId,
|
||||
hostType: 'cloudly',
|
||||
appName: serviceNameArg,
|
||||
runtimeStatus: 'unknown',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async requireHostedAppIdentity(identityArg: IHostedAppRuntimeIdentity): Promise<Service> {
|
||||
const services = await this.cloudlyRef.serviceManager.CService.getInstances({});
|
||||
const service = services.find((serviceArg) => {
|
||||
const serviceData = serviceArg.data as TExtendedServiceData;
|
||||
return (
|
||||
serviceData.hostedAppLifecycle?.appInstanceId === identityArg?.appInstanceId ||
|
||||
serviceData.environment?.SERVEZONE_APP_INSTANCE_ID === identityArg?.appInstanceId
|
||||
);
|
||||
});
|
||||
if (!service) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Hosted app service not found');
|
||||
}
|
||||
const serviceData = service.data as TExtendedServiceData;
|
||||
if (serviceData.environment?.SERVEZONE_APP_CONTROL_TOKEN !== identityArg?.appControlToken) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Hosted app identity is invalid');
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
private async getUpgradeState(serviceArg: Service): Promise<IHostedAppUpgradeState> {
|
||||
const serviceData = serviceArg.data as TExtendedServiceData;
|
||||
const latestOperation = this.cloudlyRef.appStoreManager
|
||||
.getUpgradeOperations()
|
||||
.find((operationArg) => operationArg.serviceId === serviceArg.id);
|
||||
if (latestOperation) {
|
||||
return {
|
||||
status: latestOperation.status === 'running' ? 'running' : latestOperation.status,
|
||||
appTemplateId: latestOperation.appTemplateId,
|
||||
currentVersion: latestOperation.fromVersion,
|
||||
targetVersion: latestOperation.targetVersion,
|
||||
operationId: latestOperation.id,
|
||||
warnings: latestOperation.warnings,
|
||||
error: latestOperation.error,
|
||||
startedAt: latestOperation.startedAt,
|
||||
updatedAt: latestOperation.updatedAt,
|
||||
completedAt: latestOperation.completedAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (!serviceData.appTemplateId || !serviceData.appTemplateVersion) {
|
||||
return { status: 'unknown' };
|
||||
}
|
||||
|
||||
const upgradeableServices = await this.cloudlyRef.appStoreManager.getUpgradeableAppStoreServices();
|
||||
const upgradeable = upgradeableServices.find((serviceArg2) => serviceArg2.serviceId === serviceArg.id);
|
||||
if (!upgradeable) {
|
||||
return {
|
||||
status: 'upToDate',
|
||||
appTemplateId: serviceData.appTemplateId,
|
||||
currentVersion: serviceData.appTemplateVersion,
|
||||
latestVersion: serviceData.appTemplateVersion,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'available',
|
||||
appTemplateId: upgradeable.appTemplateId,
|
||||
currentVersion: upgradeable.currentVersion,
|
||||
latestVersion: upgradeable.latestVersion,
|
||||
targetVersion: upgradeable.latestVersion,
|
||||
};
|
||||
}
|
||||
|
||||
private async getLifecycleState(serviceArg: Service): Promise<IHostedAppLifecycleState> {
|
||||
const serviceData = serviceArg.data as TExtendedServiceData;
|
||||
const appInstanceId = serviceData.hostedAppLifecycle?.appInstanceId || serviceData.environment?.SERVEZONE_APP_INSTANCE_ID;
|
||||
const state: IHostedAppLifecycleState = {
|
||||
...(serviceData.hostedAppLifecycle || ({} as IHostedAppLifecycleState)),
|
||||
appInstanceId: appInstanceId || '',
|
||||
hostType: 'cloudly',
|
||||
appName: serviceData.hostedAppLifecycle?.appName || serviceData.name,
|
||||
publicUrl: serviceData.hostedAppLifecycle?.publicUrl || (serviceData.domains?.[0]?.name ? `https://${serviceData.domains[0].name}` : undefined),
|
||||
upgradeState: await this.getUpgradeState(serviceArg),
|
||||
};
|
||||
serviceData.hostedAppLifecycle = state;
|
||||
serviceArg.data = serviceData;
|
||||
await serviceArg.save();
|
||||
return state;
|
||||
}
|
||||
|
||||
private async updateLifecycleState(serviceArg: Service, stateArg: IHostedAppLifecycleState): Promise<IHostedAppLifecycleState> {
|
||||
const serviceData = serviceArg.data as TExtendedServiceData;
|
||||
serviceData.hostedAppLifecycle = stateArg;
|
||||
serviceArg.data = serviceData;
|
||||
await serviceArg.save();
|
||||
return await this.getLifecycleState(serviceArg);
|
||||
}
|
||||
|
||||
private registerHandlers() {
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_ReportLifecycleState>(
|
||||
'hostedAppReportLifecycleState',
|
||||
async (dataArg) => {
|
||||
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||
const existingState = await this.getLifecycleState(service);
|
||||
const state = await this.updateLifecycleState(service, {
|
||||
...existingState,
|
||||
...dataArg.report,
|
||||
appInstanceId: existingState.appInstanceId,
|
||||
hostType: 'cloudly',
|
||||
reportedAt: Date.now(),
|
||||
});
|
||||
return { state };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_GetLifecycleState>(
|
||||
'hostedAppGetLifecycleState',
|
||||
async (dataArg) => {
|
||||
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||
return { state: await this.getLifecycleState(service) };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_RequestBootstrapAction>(
|
||||
'hostedAppRequestBootstrapAction',
|
||||
async (dataArg) => {
|
||||
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||
const existingState = await this.getLifecycleState(service);
|
||||
const now = Date.now();
|
||||
const action = {
|
||||
...dataArg.action,
|
||||
id: dataArg.action.id || plugins.smartunique.shortId(12),
|
||||
status: 'ready' as const,
|
||||
label: dataArg.action.label || 'Initial setup',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const state = await this.updateLifecycleState(service, {
|
||||
...existingState,
|
||||
runtimeStatus: 'setupRequired',
|
||||
bootstrapAction: action,
|
||||
reportedAt: now,
|
||||
});
|
||||
return { action, state };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_CompleteBootstrapAction>(
|
||||
'hostedAppCompleteBootstrapAction',
|
||||
async (dataArg) => {
|
||||
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||
const existingState = await this.getLifecycleState(service);
|
||||
const now = Date.now();
|
||||
const bootstrapAction = existingState.bootstrapAction
|
||||
? {
|
||||
...existingState.bootstrapAction,
|
||||
id: dataArg.actionId || existingState.bootstrapAction.id,
|
||||
status: 'completed' as const,
|
||||
message: dataArg.message || existingState.bootstrapAction.message,
|
||||
updatedAt: now,
|
||||
completedAt: now,
|
||||
}
|
||||
: undefined;
|
||||
const state = await this.updateLifecycleState(service, {
|
||||
...existingState,
|
||||
runtimeStatus: existingState.runtimeStatus === 'setupRequired' ? 'running' : existingState.runtimeStatus,
|
||||
bootstrapAction,
|
||||
reportedAt: now,
|
||||
});
|
||||
return { state };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_StartManagedUpgrade>(
|
||||
'hostedAppStartManagedUpgrade',
|
||||
async (dataArg) => {
|
||||
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||
const upgradeState = await this.getUpgradeState(service);
|
||||
const targetVersion = dataArg.targetVersion || upgradeState.targetVersion || upgradeState.latestVersion;
|
||||
if (!targetVersion) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No managed upgrade target is available');
|
||||
}
|
||||
const operation = await this.cloudlyRef.appStoreManager.startHostedAppUpgrade(service.id, targetVersion);
|
||||
const nextUpgradeState: IHostedAppUpgradeState = {
|
||||
status: 'running',
|
||||
appTemplateId: operation.appTemplateId,
|
||||
currentVersion: operation.fromVersion,
|
||||
targetVersion: operation.targetVersion,
|
||||
operationId: operation.id,
|
||||
warnings: operation.warnings,
|
||||
startedAt: operation.startedAt,
|
||||
updatedAt: operation.updatedAt,
|
||||
};
|
||||
const existingState = await this.getLifecycleState(service);
|
||||
const state = await this.updateLifecycleState(service, {
|
||||
...existingState,
|
||||
upgradeState: nextUpgradeState,
|
||||
reportedAt: Date.now(),
|
||||
});
|
||||
return { upgradeState: nextUpgradeState, state };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_GetManagedUpgradeStatus>(
|
||||
'hostedAppGetManagedUpgradeStatus',
|
||||
async (dataArg) => {
|
||||
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||
return { upgradeState: await this.getUpgradeState(service) };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_Admin_GetHostedAppParentUpgradeStatus>(
|
||||
'getHostedAppParentUpgradeStatus',
|
||||
async (dataArg) => {
|
||||
await this.passAdminIdentity(dataArg);
|
||||
return await this.getParentUpgradeStatus();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_Admin_StartHostedAppParentUpgrade>(
|
||||
'startHostedAppParentUpgrade',
|
||||
async (dataArg) => {
|
||||
await this.passAdminIdentity(dataArg);
|
||||
return await this.startParentUpgrade(dataArg.targetVersion);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async passAdminIdentity(dataArg: { identity: plugins.servezoneInterfaces.data.IIdentity }) {
|
||||
await plugins.smartguard.passGuardsOrReject(dataArg, [
|
||||
this.cloudlyRef.authManager.adminIdentityGuard,
|
||||
]);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -8,9 +8,10 @@ export { path, crypto, stream, fsPromises };
|
||||
|
||||
// @apiglobal scope
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces';
|
||||
import * as typedsocket from '@api.global/typedsocket';
|
||||
|
||||
export { typedrequest, typedsocket };
|
||||
export { typedrequest, typedrequestInterfaces, typedsocket };
|
||||
|
||||
// @apiclient.xyz scope
|
||||
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/cloudly',
|
||||
version: '6.2.0',
|
||||
version: '6.4.0',
|
||||
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
|
||||
}
|
||||
|
||||
@@ -92,6 +92,13 @@ export interface IAppStoreState {
|
||||
upgradeOperations: IAppStoreUpgradeOperation[];
|
||||
}
|
||||
|
||||
export interface IHostedRuntimeState {
|
||||
isHosted: boolean;
|
||||
loading: boolean;
|
||||
unavailableReason?: string;
|
||||
upgradeState: plugins.interfaces.data.IHostedAppUpgradeState | null;
|
||||
}
|
||||
|
||||
const emptyDataState: IDataState = {
|
||||
secretGroups: [],
|
||||
secretBundles: [],
|
||||
@@ -117,6 +124,12 @@ const emptyAppStoreState: IAppStoreState = {
|
||||
upgradeOperations: [],
|
||||
};
|
||||
|
||||
const emptyHostedRuntimeState: IHostedRuntimeState = {
|
||||
isHosted: false,
|
||||
loading: false,
|
||||
upgradeState: null,
|
||||
};
|
||||
|
||||
interface IReq_AdminValidateIdentity {
|
||||
method: 'adminValidateIdentity';
|
||||
request: {
|
||||
@@ -183,6 +196,8 @@ export const logoutAction = loginStatePart.createAction(async (statePartArg) =>
|
||||
apiClient.identity = null;
|
||||
dataState.setState({ ...emptyDataState });
|
||||
appStoreStatePart.setState({ ...emptyAppStoreState });
|
||||
hostedRuntimeStatePart.setState({ ...emptyHostedRuntimeState });
|
||||
clearHostedRuntimeUpgradePoll();
|
||||
} catch {}
|
||||
return {
|
||||
...currentState,
|
||||
@@ -202,6 +217,12 @@ export const appStoreStatePart = await appstate.getStatePart<IAppStoreState>(
|
||||
'soft',
|
||||
);
|
||||
|
||||
export const hostedRuntimeStatePart = await appstate.getStatePart<IHostedRuntimeState>(
|
||||
'hostedRuntime',
|
||||
{ ...emptyHostedRuntimeState },
|
||||
'soft',
|
||||
);
|
||||
|
||||
// Shared API client instance (used by UI actions)
|
||||
type TCloudlyApiClientWithNullableIdentity = Omit<plugins.servezoneApi.CloudlyApiClient, 'identity'> & {
|
||||
identity: plugins.interfaces.data.IIdentity | null;
|
||||
@@ -303,6 +324,8 @@ export const invalidateIdentity = async (reasonArg = 'identity is not valid'): P
|
||||
});
|
||||
dataState.setState({ ...emptyDataState });
|
||||
appStoreStatePart.setState({ ...emptyAppStoreState });
|
||||
hostedRuntimeStatePart.setState({ ...emptyHostedRuntimeState });
|
||||
clearHostedRuntimeUpgradePoll();
|
||||
} finally {
|
||||
identityInvalidationRunning = false;
|
||||
}
|
||||
@@ -865,6 +888,91 @@ const getIdentityForRequest = () => {
|
||||
return identity;
|
||||
};
|
||||
|
||||
let hostedRuntimePollTimer: number | undefined;
|
||||
|
||||
function clearHostedRuntimeUpgradePoll() {
|
||||
if (hostedRuntimePollTimer) {
|
||||
window.clearTimeout(hostedRuntimePollTimer);
|
||||
hostedRuntimePollTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleHostedRuntimeUpgradePoll = (stateArg: IHostedRuntimeState) => {
|
||||
clearHostedRuntimeUpgradePoll();
|
||||
if (stateArg.upgradeState?.status !== 'running') {
|
||||
return;
|
||||
}
|
||||
hostedRuntimePollTimer = window.setTimeout(() => {
|
||||
void hostedRuntimeStatePart.dispatchAction(fetchHostedRuntimeUpgradeStatusAction, null);
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
export const fetchHostedRuntimeUpgradeStatusAction = hostedRuntimeStatePart.createAction(
|
||||
async (statePartArg) => {
|
||||
const currentState = statePartArg.getState() || { ...emptyHostedRuntimeState };
|
||||
statePartArg.setState({ ...currentState, loading: true });
|
||||
try {
|
||||
const request = new plugins.typedrequest.TypedRequest<plugins.interfaces.requests.hostedapp.IReq_Admin_GetHostedAppParentUpgradeStatus>(
|
||||
'/typedrequest',
|
||||
'getHostedAppParentUpgradeStatus',
|
||||
);
|
||||
const response = await request.fire({ identity: getIdentityForRequest() });
|
||||
const nextState: IHostedRuntimeState = {
|
||||
isHosted: response.isHosted,
|
||||
loading: false,
|
||||
unavailableReason: response.unavailableReason,
|
||||
upgradeState: response.upgradeState,
|
||||
};
|
||||
scheduleHostedRuntimeUpgradePoll(nextState);
|
||||
return nextState;
|
||||
} catch (error) {
|
||||
const nextState: IHostedRuntimeState = {
|
||||
...currentState,
|
||||
loading: false,
|
||||
unavailableReason: getErrorText(error) || 'Could not load hosted runtime status.',
|
||||
};
|
||||
scheduleHostedRuntimeUpgradePoll(nextState);
|
||||
return nextState;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const startHostedRuntimeParentUpgradeAction = hostedRuntimeStatePart.createAction<{
|
||||
targetVersion?: string;
|
||||
} | null>(
|
||||
async (statePartArg, payloadArg) => {
|
||||
const currentState = statePartArg.getState() || { ...emptyHostedRuntimeState };
|
||||
statePartArg.setState({ ...currentState, loading: true });
|
||||
try {
|
||||
const request = new plugins.typedrequest.TypedRequest<plugins.interfaces.requests.hostedapp.IReq_Admin_StartHostedAppParentUpgrade>(
|
||||
'/typedrequest',
|
||||
'startHostedAppParentUpgrade',
|
||||
);
|
||||
const response = await request.fire({
|
||||
identity: getIdentityForRequest(),
|
||||
targetVersion: payloadArg?.targetVersion,
|
||||
});
|
||||
const nextState: IHostedRuntimeState = {
|
||||
isHosted: response.isHosted,
|
||||
loading: false,
|
||||
unavailableReason: response.unavailableReason,
|
||||
upgradeState: response.upgradeState,
|
||||
};
|
||||
scheduleHostedRuntimeUpgradePoll(nextState);
|
||||
return nextState;
|
||||
} catch (error) {
|
||||
const nextState: IHostedRuntimeState = {
|
||||
...currentState,
|
||||
loading: false,
|
||||
unavailableReason: getErrorText(error) || 'Could not start hosted runtime upgrade.',
|
||||
};
|
||||
statePartArg.setState(nextState);
|
||||
scheduleHostedRuntimeUpgradePoll(nextState);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchAppStoreTemplatesAction = appStoreStatePart.createAction(
|
||||
async (statePartArg) => {
|
||||
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreTemplates');
|
||||
|
||||
@@ -42,8 +42,8 @@ export class CloudlyViewImages extends DeesElement {
|
||||
.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; overflow-wrap: anywhere; }
|
||||
.back-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); }
|
||||
.summary-card, .detail-card { background: var(--ci-shade-1, #09090b); border: 1px solid var(--ci-shade-2, #27272a); border-radius: 9px; padding: 16px; }
|
||||
.spaced-card { margin-top: 14px; }
|
||||
.detail-card { background: var(--ci-shade-1, #09090b); border: 1px solid var(--ci-shade-2, #27272a); border-radius: 9px; padding: 16px; }
|
||||
.spaced-table, .spaced-card { margin-top: 14px; }
|
||||
.details-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 14px; }
|
||||
.section-title { font-size: 14px; font-weight: 700; color: var(--ci-shade-7, #e4e4e7); margin-bottom: 10px; }
|
||||
.kv-list { display: grid; gap: 8px; }
|
||||
@@ -172,43 +172,43 @@ export class CloudlyViewImages extends DeesElement {
|
||||
|
||||
<dees-statsgrid .tiles=${this.getImageStatsTiles(image)} .minTileWidth=${220} .gap=${12}></dees-statsgrid>
|
||||
|
||||
<div class="detail-card">
|
||||
<div class="section-title">Versions</div>
|
||||
<dees-table
|
||||
.heading1=${'Image Versions'}
|
||||
.heading2=${versions.length ? 'Stored image versions and registry metadata' : 'No versions recorded'}
|
||||
.data=${versions}
|
||||
.displayFunction=${(versionArg: plugins.interfaces.data.IImage['data']['versions'][number]) => ({
|
||||
Version: versionArg.versionString,
|
||||
Source: this.renderSourceBadge(versionArg.source),
|
||||
Size: this.formatBytes(versionArg.size),
|
||||
Digest: versionArg.digest || '-',
|
||||
Repository: versionArg.registryRepository || '-',
|
||||
Tag: versionArg.registryTag || '-',
|
||||
Storage: versionArg.storagePath || '-',
|
||||
Created: this.formatDate(versionArg.createdAt),
|
||||
})}
|
||||
></dees-table>
|
||||
|
||||
${servicesUsingImage.length ? html`
|
||||
<dees-table
|
||||
.heading1=${'Image Versions'}
|
||||
.heading2=${versions.length ? 'Stored image versions and registry metadata' : 'No versions recorded'}
|
||||
.data=${versions}
|
||||
.displayFunction=${(versionArg: plugins.interfaces.data.IImage['data']['versions'][number]) => ({
|
||||
Version: versionArg.versionString,
|
||||
Source: this.renderSourceBadge(versionArg.source),
|
||||
Size: this.formatBytes(versionArg.size),
|
||||
Digest: versionArg.digest || '-',
|
||||
Repository: versionArg.registryRepository || '-',
|
||||
Tag: versionArg.registryTag || '-',
|
||||
Storage: versionArg.storagePath || '-',
|
||||
Created: this.formatDate(versionArg.createdAt),
|
||||
class="spaced-table"
|
||||
.heading1=${'Service Usage'}
|
||||
.heading2=${'Services currently configured with this image ID'}
|
||||
.data=${servicesUsingImage}
|
||||
.displayFunction=${(serviceArg: plugins.interfaces.data.IService) => ({
|
||||
Name: serviceArg.data.name,
|
||||
Version: serviceArg.data.imageVersion || '-',
|
||||
Category: serviceArg.data.serviceCategory || 'workload',
|
||||
Strategy: serviceArg.data.deploymentStrategy || 'custom',
|
||||
Domains: serviceArg.data.domains?.map((domainArg) => domainArg.name).join(', ') || '-',
|
||||
Deployments: serviceArg.data.deploymentIds?.length || 0,
|
||||
})}
|
||||
></dees-table>
|
||||
</div>
|
||||
|
||||
<div class="detail-card spaced-card">
|
||||
<div class="section-title">Services Using This Image</div>
|
||||
${servicesUsingImage.length ? html`
|
||||
<dees-table
|
||||
.heading1=${'Service Usage'}
|
||||
.heading2=${'Services currently configured with this image ID'}
|
||||
.data=${servicesUsingImage}
|
||||
.displayFunction=${(serviceArg: plugins.interfaces.data.IService) => ({
|
||||
Name: serviceArg.data.name,
|
||||
Version: serviceArg.data.imageVersion || '-',
|
||||
Category: serviceArg.data.serviceCategory || 'workload',
|
||||
Strategy: serviceArg.data.deploymentStrategy || 'custom',
|
||||
Domains: serviceArg.data.domains?.map((domainArg) => domainArg.name).join(', ') || '-',
|
||||
Deployments: serviceArg.data.deploymentIds?.length || 0,
|
||||
})}
|
||||
></dees-table>
|
||||
` : html`<div class="empty-state">No services currently reference this image.</div>`}
|
||||
</div>
|
||||
` : html`
|
||||
<div class="detail-card spaced-card">
|
||||
<div class="section-title">Services Using This Image</div>
|
||||
<div class="empty-state">No services currently reference this image.</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div class="details-grid">
|
||||
<div class="detail-card">
|
||||
|
||||
@@ -368,66 +368,65 @@ export class CloudlyViewServices extends DeesElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px;">
|
||||
<div>
|
||||
<div class="section-title">Deployments</div>
|
||||
<div class="detail-subtitle">Container-level runtime actions happen here.</div>
|
||||
</div>
|
||||
<button class="back-button" @click=${() => this.loadDeploymentsForService(service)}>Refresh</button>
|
||||
</div>
|
||||
${this.deploymentsLoading ? html`<div class="detail-subtitle">Loading deployments...</div>` : html`
|
||||
<dees-table
|
||||
.heading1=${'Live Deployments'}
|
||||
.heading2=${this.serviceDeployments.length ? 'Docker Swarm tasks reported by connected Coreflows' : 'No live deployments reported'}
|
||||
.data=${this.serviceDeployments}
|
||||
.displayFunction=${(deploymentArg: any) => ({
|
||||
Status: this.renderStatusBadge(deploymentArg.status),
|
||||
Node: deploymentArg.nodeName || deploymentArg.nodeId || '-',
|
||||
Slot: deploymentArg.slot || '-',
|
||||
Version: deploymentArg.version || service.data.imageVersion,
|
||||
Container: deploymentArg.containerId ? deploymentArg.containerId.slice(0, 12) : '-',
|
||||
CPU: deploymentArg.resourceUsage ? `${deploymentArg.resourceUsage.cpuUsagePercent.toFixed(1)}%` : '-',
|
||||
Memory: deploymentArg.resourceUsage ? `${deploymentArg.resourceUsage.memoryUsedMB} MB` : '-',
|
||||
Updated: deploymentArg.updatedAt ? new Date(deploymentArg.updatedAt).toLocaleString() : '-',
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Details',
|
||||
iconName: 'lucide:Eye',
|
||||
type: ['contextmenu', 'inRow', 'doubleClick'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.showDeploymentDetailsModal(actionDataArg.item);
|
||||
},
|
||||
${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: 'Refresh',
|
||||
iconName: 'refresh-cw',
|
||||
type: ['header'],
|
||||
actionFunc: async () => {
|
||||
await this.loadDeploymentsForService(service);
|
||||
},
|
||||
{
|
||||
name: 'Open IDE',
|
||||
iconName: 'terminal',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.openDeploymentWorkspace(actionDataArg.item);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Details',
|
||||
iconName: 'lucide:Eye',
|
||||
type: ['contextmenu', 'inRow', 'doubleClick'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.showDeploymentDetailsModal(actionDataArg.item);
|
||||
},
|
||||
{
|
||||
name: 'Restart',
|
||||
iconName: 'refresh-cw',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.restartDeployment(actionDataArg.item);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Open IDE',
|
||||
iconName: 'terminal',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.openDeploymentWorkspace(actionDataArg.item);
|
||||
},
|
||||
{
|
||||
name: 'Kill Container',
|
||||
iconName: 'skull',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.confirmKillDeployment(actionDataArg.item);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Restart',
|
||||
iconName: 'refresh-cw',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.restartDeployment(actionDataArg.item);
|
||||
},
|
||||
] as plugins.deesCatalog.ITableAction[]}
|
||||
></dees-table>
|
||||
`}
|
||||
</div>
|
||||
},
|
||||
{
|
||||
name: 'Kill Container',
|
||||
iconName: 'skull',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.confirmKillDeployment(actionDataArg.item);
|
||||
},
|
||||
},
|
||||
] as plugins.deesCatalog.ITableAction[]}
|
||||
></dees-table>
|
||||
`}
|
||||
|
||||
<div class="details-grid">
|
||||
<div class="detail-card">
|
||||
|
||||
@@ -23,8 +23,29 @@ export class CloudlyViewSettings extends DeesElement {
|
||||
@state()
|
||||
private accessor testResults: {[key: string]: {success: boolean; message: string}} = {};
|
||||
|
||||
@state()
|
||||
private accessor hostedRuntime: appstate.IHostedRuntimeState = {
|
||||
isHosted: false,
|
||||
loading: false,
|
||||
upgradeState: null,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const hostedRuntimeSubscription = appstate.hostedRuntimeStatePart
|
||||
.select((stateArg) => stateArg)
|
||||
.subscribe((stateArg) => {
|
||||
this.hostedRuntime = stateArg;
|
||||
});
|
||||
this.rxSubscriptions.push(hostedRuntimeSubscription);
|
||||
const loginSubscription = appstate.loginStatePart
|
||||
.select((stateArg) => stateArg.identity)
|
||||
.subscribe((identityArg) => {
|
||||
if (identityArg) {
|
||||
void this.refreshHostedRuntimeStatus();
|
||||
}
|
||||
});
|
||||
this.rxSubscriptions.push(loginSubscription);
|
||||
this.loadSettings();
|
||||
}
|
||||
|
||||
@@ -41,10 +62,24 @@ export class CloudlyViewSettings extends DeesElement {
|
||||
dees-panel { margin-bottom: 16px; }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.form-grid.single { grid-template-columns: 1fr; }
|
||||
.runtime-panel { display: grid; gap: 16px; }
|
||||
.runtime-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||||
.runtime-card { border: 1px solid var(--ci-shade-2, #27272a); border-radius: 8px; padding: 12px; background: var(--ci-shade-1, #09090b); }
|
||||
.runtime-label { color: var(--ci-shade-4, #71717a); font-size: 12px; margin-bottom: 6px; }
|
||||
.runtime-value { color: var(--ci-shade-7, #e4e4e7); font-size: 14px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.runtime-message { color: var(--ci-shade-5, #a1a1aa); font-size: 13px; line-height: 1.5; }
|
||||
.runtime-message.error { color: #ef4444; }
|
||||
.runtime-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
@media (max-width: 768px) { .form-grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 768px) { .runtime-grid { grid-template-columns: 1fr; } }
|
||||
`,
|
||||
];
|
||||
|
||||
public async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await this.refreshHostedRuntimeStatus();
|
||||
}
|
||||
|
||||
private async loadSettings() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
@@ -102,6 +137,124 @@ export class CloudlyViewSettings extends DeesElement {
|
||||
return html`<dees-badge .type=${result.success ? 'success' : 'error'} .text=${result.success ? 'Connected' : 'Failed'}></dees-badge>`;
|
||||
}
|
||||
|
||||
private async refreshHostedRuntimeStatus() {
|
||||
if (!appstate.loginStatePart.getState()?.identity) {
|
||||
return;
|
||||
}
|
||||
await appstate.hostedRuntimeStatePart.dispatchAction(appstate.fetchHostedRuntimeUpgradeStatusAction, null);
|
||||
}
|
||||
|
||||
private getHostedRuntimeBadgeType() {
|
||||
const status = this.hostedRuntime.upgradeState?.status;
|
||||
if (!this.hostedRuntime.isHosted) return 'info';
|
||||
if (status === 'failed') return 'error';
|
||||
if (status === 'upToDate' || status === 'success') return 'success';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
private getHostedRuntimeStatusText() {
|
||||
if (!this.hostedRuntime.isHosted) return 'Not hosted';
|
||||
const status = this.hostedRuntime.upgradeState?.status || 'unknown';
|
||||
switch (status) {
|
||||
case 'upToDate': return 'Up to date';
|
||||
case 'available': return 'Update available';
|
||||
case 'running': return 'Upgrade running';
|
||||
case 'success': return 'Upgrade complete';
|
||||
case 'failed': return 'Upgrade failed';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
private getHostedRuntimeMessage() {
|
||||
if (!this.hostedRuntime.isHosted) {
|
||||
return this.hostedRuntime.unavailableReason || 'This Cloudly instance is not running as a managed hosted app.';
|
||||
}
|
||||
if (this.hostedRuntime.unavailableReason) {
|
||||
return this.hostedRuntime.unavailableReason;
|
||||
}
|
||||
const upgradeState = this.hostedRuntime.upgradeState;
|
||||
if (upgradeState?.status === 'available') {
|
||||
return `Parent host can upgrade Cloudly from ${upgradeState.currentVersion || 'current'} to ${upgradeState.targetVersion || upgradeState.latestVersion}.`;
|
||||
}
|
||||
if (upgradeState?.status === 'running') {
|
||||
return 'The parent host is upgrading this Cloudly service. Status refreshes automatically.';
|
||||
}
|
||||
if (upgradeState?.status === 'failed') {
|
||||
return upgradeState.error || 'The last parent-hosted upgrade failed.';
|
||||
}
|
||||
return 'Parent hosted runtime status is available. No upgrade action is currently required.';
|
||||
}
|
||||
|
||||
private async startHostedRuntimeUpgrade() {
|
||||
const upgradeState = this.hostedRuntime.upgradeState;
|
||||
const targetVersion = upgradeState?.targetVersion || upgradeState?.latestVersion;
|
||||
if (!targetVersion) {
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: 'No hosted runtime upgrade target is available.', type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
let upgradeStarting = false;
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Upgrade Hosted Cloudly',
|
||||
content: html`
|
||||
<div style="width: min(560px, calc(100vw - 48px)); max-width: 100%; color: var(--ci-shade-5, #a1a1aa); line-height: 1.5;">
|
||||
The parent host will upgrade this Cloudly app from ${upgradeState?.currentVersion || 'current'} to ${targetVersion} using its hosted app lifecycle controls.
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Start Upgrade',
|
||||
action: async (modalArg: any) => {
|
||||
if (upgradeStarting) return;
|
||||
upgradeStarting = true;
|
||||
try {
|
||||
await appstate.hostedRuntimeStatePart.dispatchAction(appstate.startHostedRuntimeParentUpgradeAction, { targetVersion });
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: 'Hosted runtime upgrade started.', type: 'success' });
|
||||
await modalArg.destroy();
|
||||
} catch (error) {
|
||||
upgradeStarting = false;
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: `Upgrade failed: ${(error as Error).message}`, type: 'error' });
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private renderHostedRuntimePanel() {
|
||||
const upgradeState = this.hostedRuntime.upgradeState;
|
||||
const canStartUpgrade = this.hostedRuntime.isHosted && upgradeState?.status === 'available' && !this.hostedRuntime.loading;
|
||||
return html`
|
||||
<dees-panel .title=${'Hosted Runtime'} .subtitle=${'Manage this Cloudly instance through its parent serve.zone host'} .variant=${'outline'}>
|
||||
<div class="runtime-panel">
|
||||
<div class="runtime-grid">
|
||||
<div class="runtime-card">
|
||||
<div class="runtime-label">Runtime</div>
|
||||
<div class="runtime-value">${this.hostedRuntime.isHosted ? 'Managed hosted app' : 'Standalone'}</div>
|
||||
</div>
|
||||
<div class="runtime-card">
|
||||
<div class="runtime-label">Upgrade Status</div>
|
||||
<div class="runtime-value"><dees-badge .type=${this.getHostedRuntimeBadgeType()} .text=${this.getHostedRuntimeStatusText()}></dees-badge></div>
|
||||
</div>
|
||||
<div class="runtime-card">
|
||||
<div class="runtime-label">Version</div>
|
||||
<div class="runtime-value">${upgradeState?.currentVersion || '-'}${upgradeState?.latestVersion ? ` / ${upgradeState.latestVersion}` : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=${`runtime-message ${this.hostedRuntime.unavailableReason || upgradeState?.status === 'failed' ? 'error' : ''}`}>
|
||||
${this.getHostedRuntimeMessage()}
|
||||
</div>
|
||||
${upgradeState?.warnings?.length ? html`<div class="runtime-message">${upgradeState.warnings.join(' | ')}</div>` : ''}
|
||||
<div class="runtime-actions">
|
||||
<dees-button .text=${this.hostedRuntime.loading ? 'Refreshing...' : 'Refresh Status'} .type=${'secondary'} .disabled=${this.hostedRuntime.loading} @click=${() => this.refreshHostedRuntimeStatus()}></dees-button>
|
||||
<dees-button .text=${upgradeState?.status === 'running' ? 'Upgrade Running' : 'Start Parent Upgrade'} .type=${'primary'} .disabled=${!canStartUpgrade} @click=${() => this.startHostedRuntimeUpgrade()}></dees-button>
|
||||
</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.isLoading && Object.keys(this.settings).length === 0) {
|
||||
return html`<div class="loading-container"><dees-spinner></dees-spinner></div>`;
|
||||
@@ -109,6 +262,7 @@ export class CloudlyViewSettings extends DeesElement {
|
||||
return html`
|
||||
<cloudly-sectionheading>Settings</cloudly-sectionheading>
|
||||
<div class="settings-container">
|
||||
${this.renderHostedRuntimePanel()}
|
||||
<dees-form @formData=${(e: CustomEvent) => { this.saveSettings((e.detail as any).data); }}>
|
||||
<dees-panel .title=${'Hetzner Cloud'} .subtitle=${'Configure Hetzner Cloud API access'} .variant=${'outline'}>
|
||||
<div class="test-status">
|
||||
|
||||
@@ -266,32 +266,30 @@ export class CloudlyViewTasks extends DeesElement {
|
||||
|
||||
<cloudly-sectionheading>Execution History</cloudly-sectionheading>
|
||||
|
||||
<dees-panel .title=${'Recent Executions'} .subtitle=${'History of task runs and their outcomes'} .variant=${'outline'}>
|
||||
<dees-table
|
||||
.heading1=${'Task Executions'}
|
||||
.heading2=${'History of task runs and their outcomes'}
|
||||
.data=${this.data.taskExecutions || []}
|
||||
.displayFunction=${(itemArg: plugins.interfaces.data.ITaskExecution) => {
|
||||
return {
|
||||
Task: itemArg.data.taskName,
|
||||
Status: html`<span class="status-badge status-${itemArg.data.status}">${itemArg.data.status}</span>`,
|
||||
'Started At': formatDate(itemArg.data.startedAt),
|
||||
Duration: itemArg.data.duration ? formatDuration(itemArg.data.duration) : '-',
|
||||
'Triggered By': itemArg.data.triggeredBy,
|
||||
Logs: itemArg.data.logs?.length || 0,
|
||||
} as any;
|
||||
}}
|
||||
.actionFunction=${async (itemArg: plugins.interfaces.data.ITaskExecution) => {
|
||||
const actions: any[] = [
|
||||
{ name: 'View Details', iconName: 'lucide:Eye', type: ['inRow'], actionFunc: async () => { this.selectedExecution = itemArg; } }
|
||||
];
|
||||
if (itemArg.data.status === 'running') {
|
||||
actions.push({ name: 'Cancel', iconName: 'lucide:SquareX', type: ['inRow'], actionFunc: async () => { await appstate.dataState.dispatchAction(appstate.taskActions.cancelTask, { executionId: itemArg.id }); await this.loadExecutionsWithFilter(); } });
|
||||
}
|
||||
return actions;
|
||||
}}
|
||||
></dees-table>
|
||||
</dees-panel>
|
||||
<dees-table
|
||||
.heading1=${'Task Executions'}
|
||||
.heading2=${'History of task runs and their outcomes'}
|
||||
.data=${this.data.taskExecutions || []}
|
||||
.displayFunction=${(itemArg: plugins.interfaces.data.ITaskExecution) => {
|
||||
return {
|
||||
Task: itemArg.data.taskName,
|
||||
Status: html`<span class="status-badge status-${itemArg.data.status}">${itemArg.data.status}</span>`,
|
||||
'Started At': formatDate(itemArg.data.startedAt),
|
||||
Duration: itemArg.data.duration ? formatDuration(itemArg.data.duration) : '-',
|
||||
'Triggered By': itemArg.data.triggeredBy,
|
||||
Logs: itemArg.data.logs?.length || 0,
|
||||
} as any;
|
||||
}}
|
||||
.actionFunction=${async (itemArg: plugins.interfaces.data.ITaskExecution) => {
|
||||
const actions: any[] = [
|
||||
{ name: 'View Details', iconName: 'lucide:Eye', type: ['inRow'], actionFunc: async () => { this.selectedExecution = itemArg; } }
|
||||
];
|
||||
if (itemArg.data.status === 'running') {
|
||||
actions.push({ name: 'Cancel', iconName: 'lucide:SquareX', type: ['inRow'], actionFunc: async () => { await appstate.dataState.dispatchAction(appstate.taskActions.cancelTask, { executionId: itemArg.id }); await this.loadExecutionsWithFilter(); } });
|
||||
}
|
||||
return actions;
|
||||
}}
|
||||
></dees-table>
|
||||
|
||||
${this.selectedExecution ? html`
|
||||
<cloudly-sectionheading>Execution Details</cloudly-sectionheading>
|
||||
|
||||
Reference in New Issue
Block a user