Compare commits

...

4 Commits

Author SHA1 Message Date
jkunz 7f0c968b5c v6.4.1
Docker (tags) / release (push) Failing after 1s
2026-05-27 21:33:02 +00:00
jkunz 78d7479b4a fix(appstore): handle App Store backend failures and empty RPC responses 2026-05-27 21:32:32 +00:00
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
11 changed files with 556 additions and 40 deletions
+25
View File
@@ -3,6 +3,31 @@
## Pending
## 2026-05-27 - 6.4.1
### Fixes
- return safe App Store backend errors for template, config, and install failures
- guard App Store client actions against empty typed RPC responses
- bump `@api.global/typedrequest` to `3.3.2` and `@serve.zone/api` to `^5.3.9`
- handle App Store backend failures and empty RPC responses (appstore)
- return sanitized App Store backend errors for template, config, and install failures
- validate App Store typed RPC responses before updating client state or returning results
- bump typedrequest and serve.zone API dependencies
## 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)
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@serve.zone/cloudly",
"version": "6.3.1",
"version": "6.4.1",
"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",
@@ -34,7 +34,7 @@
"@types/node": "^25.9.1"
},
"dependencies": {
"@api.global/typedrequest": "3.3.1",
"@api.global/typedrequest": "3.3.2",
"@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.3",
@@ -78,9 +78,9 @@
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/taskbuffer": "^8.0.2",
"@push.rocks/webjwt": "^1.0.10",
"@serve.zone/api": "^5.3.8",
"@serve.zone/api": "^5.3.9",
"@serve.zone/appstore": "^0.2.0",
"@serve.zone/interfaces": "^6.1.0",
"@serve.zone/interfaces": "^6.2.0",
"@tsclass/tsclass": "^9.5.1"
},
"files": [
+23 -23
View File
@@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@api.global/typedrequest':
specifier: 3.3.1
version: 3.3.1
specifier: 3.3.2
version: 3.3.2
'@api.global/typedrequest-interfaces':
specifier: ^3.0.19
version: 3.0.19
@@ -141,14 +141,14 @@ importers:
specifier: ^1.0.10
version: 1.0.10
'@serve.zone/api':
specifier: ^5.3.8
version: 5.3.8(@push.rocks/smartserve@2.0.4)
specifier: ^5.3.9
version: 5.3.9(@push.rocks/smartserve@2.0.4)
'@serve.zone/appstore':
specifier: ^0.2.0
version: 0.2.0
'@serve.zone/interfaces':
specifier: ^6.1.0
version: 6.1.0
specifier: ^6.2.0
version: 6.2.0
'@tsclass/tsclass':
specifier: ^9.5.1
version: 9.5.1
@@ -262,8 +262,8 @@ packages:
'@api.global/typedrequest-interfaces@3.0.19':
resolution: {integrity: sha512-uuHUXJeOy/inWSDrwD0Cwax2rovpxYllDhM2RWh+6mVpQuNmZ3uw6IVg6dA2G1rOe24Ebs+Y9SzEogo+jYN7vw==}
'@api.global/typedrequest@3.3.1':
resolution: {integrity: sha512-uJ8uGS7T4OvnpvKlc1T6ML/CHOGKZIrgRFYYxnPKho2SZGnBFEfazWKshxlgqPsiWMZDFwX9i8c8sp+l3AGI2w==}
'@api.global/typedrequest@3.3.2':
resolution: {integrity: sha512-a48z7i9UaP48ru/LzDwPBENHOzn8maHW61rh5g3yGvnIkSWgGGPSGWFDrB44O6jE+2tTr0twh1B+zzNqI4hlIA==}
'@api.global/typedserver@8.4.6':
resolution: {integrity: sha512-kSzjzM0TenzRL73rmDiwsJR/SFJ3nPI7zFC9KWxO7nIhyMo5wgO7UMVCpjXrTYMK6c4HwbhBxEPIJb4prqakww==}
@@ -1829,8 +1829,8 @@ packages:
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@serve.zone/api@5.3.8':
resolution: {integrity: sha512-k3IU4mcHuk5pKB+X7rhYWGK+j5hyyDzFoqR3ytzG1iidvgDEIIToQJq+mB3E1v6X1+tI3WyYUaMN/TaZRz0l0w==}
'@serve.zone/api@5.3.9':
resolution: {integrity: sha512-H5T5jPhUrlZFVZLJif8HMKek1dSJ5gzWrj3cDaGj1XXfi/Ca4IJfM9qMwlIJ2CB5SLGl0Y2SlFW5wQJ8N9X9jA==}
'@serve.zone/appstore@0.2.0':
resolution: {integrity: sha512-qt2LVaRpzfJdUywllm+F0njwnN3aHc2aZHEcjc9REn1VDT47UuUEGaKkfNiosGK0GJqb1hPI/GwyuGMe4H4q7w==}
@@ -1838,8 +1838,8 @@ packages:
'@serve.zone/interfaces@5.10.0':
resolution: {integrity: sha512-8ZnP1A43UZlYwfd2j+S0Yin//didacIX2Rou9MobRuSFFgi1RQOqQcIWqOINcDk80wBDuYkyMCwHygYxD5i+Ig==}
'@serve.zone/interfaces@6.1.0':
resolution: {integrity: sha512-nhxMmMfemBaGM1xxFpbNM8/zPM4Y59mVsgz9XBNGZr6n7kn81QsY+Xcn5HnLywztuGHqgEZRWGmI4MPzORRktw==}
'@serve.zone/interfaces@6.2.0':
resolution: {integrity: sha512-7eZIdl0IcuiUReGetJnOFkewCWBTEVGJSyUHdQkjtr0FLfgyqgm4ItlJlWPVpFlapm6GxkHYmPBkwxrpOq1Bsw==}
'@shikijs/engine-oniguruma@3.23.0':
resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
@@ -4643,7 +4643,7 @@ snapshots:
'@api.global/typedrequest-interfaces@3.0.19': {}
'@api.global/typedrequest@3.3.1':
'@api.global/typedrequest@3.3.2':
dependencies:
'@api.global/typedrequest-interfaces': 3.0.19
'@push.rocks/isounique': 1.0.5
@@ -4657,7 +4657,7 @@ snapshots:
'@api.global/typedserver@8.4.6(@tiptap/pm@2.27.2)':
dependencies:
'@api.global/typedrequest': 3.3.1
'@api.global/typedrequest': 3.3.2
'@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 4.1.3(@push.rocks/smartserve@2.0.4)
'@cloudflare/workers-types': 4.20260507.1
@@ -4703,7 +4703,7 @@ snapshots:
'@api.global/typedsocket@4.1.3(@push.rocks/smartserve@2.0.4)':
dependencies:
'@api.global/typedrequest': 3.3.1
'@api.global/typedrequest': 3.3.2
'@api.global/typedrequest-interfaces': 3.0.19
'@push.rocks/isohash': 2.0.1
'@push.rocks/smartdelay': 3.1.0
@@ -5270,14 +5270,14 @@ snapshots:
'@design.estate/dees-comms@1.0.30':
dependencies:
'@api.global/typedrequest': 3.3.1
'@api.global/typedrequest': 3.3.2
'@api.global/typedrequest-interfaces': 3.0.19
'@push.rocks/smartdelay': 3.1.0
broadcast-channel: 7.3.0
'@design.estate/dees-domtools@2.5.6':
dependencies:
'@api.global/typedrequest': 3.3.1
'@api.global/typedrequest': 3.3.2
'@design.estate/dees-comms': 1.0.30
'@push.rocks/lik': 6.4.1
'@push.rocks/smartdelay': 3.1.0
@@ -6435,7 +6435,7 @@ snapshots:
'@push.rocks/qenv@6.1.4':
dependencies:
'@api.global/typedrequest': 3.3.1
'@api.global/typedrequest': 3.3.2
'@configvault.io/interfaces': 1.0.17
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartpath': 6.0.0
@@ -7039,7 +7039,7 @@ snapshots:
'@push.rocks/smartserve@2.0.4':
dependencies:
'@api.global/typedrequest': 3.3.1
'@api.global/typedrequest': 3.3.2
'@cfworker/json-schema': 4.1.1
'@push.rocks/lik': 6.4.1
'@push.rocks/smartenv': 6.1.0
@@ -7348,9 +7348,9 @@ snapshots:
'@sec-ant/readable-stream@0.4.1': {}
'@serve.zone/api@5.3.8(@push.rocks/smartserve@2.0.4)':
'@serve.zone/api@5.3.9(@push.rocks/smartserve@2.0.4)':
dependencies:
'@api.global/typedrequest': 3.3.1
'@api.global/typedrequest': 3.3.2
'@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 4.1.3(@push.rocks/smartserve@2.0.4)
'@push.rocks/smartexpect': 2.5.0
@@ -7364,7 +7364,7 @@ snapshots:
'@serve.zone/appstore@0.2.0':
dependencies:
'@serve.zone/interfaces': 6.1.0
'@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.1.0':
'@serve.zone/interfaces@6.2.0':
dependencies:
'@api.global/typedrequest-interfaces': 3.0.19
'@push.rocks/smartlog-interfaces': 3.0.2
+1
View File
@@ -1,4 +1,5 @@
minimumReleaseAgeExclude:
- '@api.global/typedrequest'
- '@serve.zone/api'
- '@serve.zone/appstore'
- '@serve.zone/interfaces'
+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.3.1',
version: '6.4.1',
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
}
+61 -7
View File
@@ -84,13 +84,53 @@ export class CloudlyAppStoreManager {
public async start() {}
public async stop() {}
private getErrorMessage(errorArg: unknown): string {
if (errorArg instanceof Error) return errorArg.message;
return String(errorArg);
}
private getSafeAppStoreErrorMessage(errorArg: unknown): string {
const message = this.getErrorMessage(errorArg);
const lowerMessage = message.toLowerCase();
if (
lowerMessage.includes('fetch') ||
lowerMessage.includes('connect') ||
lowerMessage.includes('connection refused') ||
lowerMessage.includes('network') ||
/http \d+/.test(lowerMessage)
) {
return 'The App Store backend is currently unreachable. Please retry later.';
}
if (
lowerMessage.includes('domain is required') ||
lowerMessage.includes('missing required app env var') ||
lowerMessage.includes('unsupported platform requirement') ||
lowerMessage.includes('published port') ||
lowerMessage.includes('app requires cloudly')
) {
return message;
}
return 'The App Store request failed. Please retry later.';
}
private createSafeAppStoreTypedError(actionArg: string, errorArg: unknown): plugins.typedrequest.TypedResponseError {
console.warn(`${actionArg}: ${this.getErrorMessage(errorArg)}`);
return new plugins.typedrequest.TypedResponseError(
`${actionArg}: ${this.getSafeAppStoreErrorMessage(errorArg)}`,
);
}
private registerHandlers() {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.appstore.IReq_Any_GetAppStoreTemplates>(
'getAppStoreTemplates',
async (dataArg) => {
await this.passAdminIdentity(dataArg);
return { apps: await this.getApps() };
try {
return { apps: await this.getApps() };
} catch (error) {
throw this.createSafeAppStoreTypedError('Could not load App Store templates', error);
}
},
),
);
@@ -100,10 +140,17 @@ export class CloudlyAppStoreManager {
'getAppStoreConfig',
async (dataArg) => {
await this.passAdminIdentity(dataArg);
return {
config: await this.getAppVersionConfig(dataArg.appId, dataArg.version),
appMeta: await this.getAppMeta(dataArg.appId),
};
try {
return {
config: await this.getAppVersionConfig(dataArg.appId, dataArg.version),
appMeta: await this.getAppMeta(dataArg.appId),
};
} catch (error) {
throw this.createSafeAppStoreTypedError(
`Could not load App Store details for ${dataArg.appId}@${dataArg.version || 'latest'}`,
error,
);
}
},
),
);
@@ -113,8 +160,15 @@ export class CloudlyAppStoreManager {
'installAppStoreApp',
async (dataArg) => {
await this.passAdminIdentity(dataArg);
const service = await this.installApp(dataArg.install);
return { service: await service.createSavableObject() };
try {
const service = await this.installApp(dataArg.install);
return { service: await service.createSavableObject() };
} catch (error) {
throw this.createSafeAppStoreTypedError(
`Could not install App Store app ${dataArg.install?.appId || 'unknown'}`,
error,
);
}
},
),
);
@@ -6,6 +6,12 @@ type IHostedAppLifecycleState = plugins.servezoneInterfaces.data.IHostedAppLifec
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;
};
@@ -45,6 +51,89 @@ export class CloudlyHostedAppManager {
);
}
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;
@@ -332,5 +421,31 @@ export class CloudlyHostedAppManager {
},
),
);
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,
]);
}
}
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/cloudly',
version: '6.3.1',
version: '6.4.1',
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
}
+131 -4
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,13 +888,101 @@ 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');
const response = await request.fire({ identity: getIdentityForRequest() });
if (!response?.apps) {
throw new Error('The App Store returned an empty template response. Please retry.');
}
return {
...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }),
apps: response.apps || [],
apps: response.apps,
};
},
);
@@ -880,9 +991,12 @@ export const fetchUpgradeableAppStoreServicesAction = appStoreStatePart.createAc
async (statePartArg) => {
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getUpgradeableAppStoreServices');
const response = await request.fire({ identity: getIdentityForRequest() });
if (!response?.services) {
throw new Error('The App Store returned an empty upgradeable-services response. Please retry.');
}
return {
...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }),
upgradeableServices: response.services || [],
upgradeableServices: response.services,
};
},
);
@@ -891,9 +1005,12 @@ export const fetchAppStoreUpgradeOperationsAction = appStoreStatePart.createActi
async (statePartArg) => {
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreUpgradeOperations');
const response = await request.fire({ identity: getIdentityForRequest() });
if (!response?.operations) {
throw new Error('The App Store returned an empty upgrade-operations response. Please retry.');
}
return {
...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }),
upgradeOperations: response.operations || [],
upgradeOperations: response.operations,
};
},
);
@@ -919,7 +1036,7 @@ export const startAppStoreServiceUpgradeAction = appStoreStatePart.createAction<
export const getAppStoreConfig = async (appIdArg: string, versionArg: string) => {
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreConfig');
return await request.fire({
const response = await request.fire({
identity: getIdentityForRequest(),
appId: appIdArg,
version: versionArg,
@@ -927,6 +1044,10 @@ export const getAppStoreConfig = async (appIdArg: string, versionArg: string) =>
config: plugins.interfaces.appstore.IAppStoreVersionConfig;
appMeta: plugins.interfaces.appstore.IAppStoreAppMeta;
};
if (!response?.config || !response?.appMeta) {
throw new Error('The App Store returned an empty config response. Please retry.');
}
return response;
};
export const getAppStoreUpgradePreview = async (serviceIdArg: string, targetVersionArg?: string) => {
@@ -936,6 +1057,9 @@ export const getAppStoreUpgradePreview = async (serviceIdArg: string, targetVers
serviceId: serviceIdArg,
targetVersion: targetVersionArg,
});
if (!response?.preview) {
throw new Error('The App Store returned an empty upgrade preview response. Please retry.');
}
return response.preview as IAppStoreUpgradePreview;
};
@@ -945,5 +1069,8 @@ export const installAppStoreApp = async (installArg: plugins.interfaces.appstore
identity: getIdentityForRequest(),
install: installArg,
});
if (!response?.service) {
throw new Error('The App Store returned an empty install response. Please retry.');
}
return response.service as plugins.interfaces.data.IService;
};
+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">