Compare commits

...

6 Commits

Author SHA1 Message Date
jkunz 9bac0a5f71 v6.4.0
Docker (tags) / release (push) Failing after 1s
2026-05-26 21:50:43 +00:00
jkunz 26256c92bd feat(hostedapp): add hosted Cloudly parent upgrade controls 2026-05-26 21:50:17 +00:00
jkunz c7a307c9d3 v6.3.1
Docker (tags) / release (push) Failing after 1s
2026-05-26 19:39:18 +00:00
jkunz 06d54db747 fix(ui): remove redundant wrappers around Cloudly tables 2026-05-26 19:38:52 +00:00
jkunz 756c35aa05 v6.3.0
Docker (tags) / release (push) Failing after 1s
2026-05-26 15:29:07 +00:00
jkunz 2adb86c5ea feat(hostedapp): add hosted app lifecycle protocol support 2026-05-26 15:27:00 +00:00
17 changed files with 990 additions and 171 deletions
+41
View File
@@ -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
View File
@@ -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": [
+27 -27
View File
@@ -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
+1 -7
View File
@@ -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",
+40
View File
@@ -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');
+1 -1
View File
@@ -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.'
}
+5
View File
@@ -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();
}
+12 -1
View File
@@ -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: [],
+29 -12
View File
@@ -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
View File
@@ -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';
+1 -1
View File
@@ -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.'
}
+108
View File
@@ -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');
+36 -36
View File
@@ -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">
+55 -56
View File
@@ -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">
+154
View File
@@ -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">
+24 -26
View File
@@ -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>