feat(hostedapp): add hosted Cloudly parent upgrade controls
This commit is contained in:
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
## Pending
|
## Pending
|
||||||
|
|
||||||
|
### 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
|
## 2026-05-26 - 6.3.1
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -80,7 +80,7 @@
|
|||||||
"@push.rocks/webjwt": "^1.0.10",
|
"@push.rocks/webjwt": "^1.0.10",
|
||||||
"@serve.zone/api": "^5.3.8",
|
"@serve.zone/api": "^5.3.8",
|
||||||
"@serve.zone/appstore": "^0.2.0",
|
"@serve.zone/appstore": "^0.2.0",
|
||||||
"@serve.zone/interfaces": "^6.1.0",
|
"@serve.zone/interfaces": "^6.2.0",
|
||||||
"@tsclass/tsclass": "^9.5.1"
|
"@tsclass/tsclass": "^9.5.1"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
Generated
+6
-6
@@ -147,8 +147,8 @@ importers:
|
|||||||
specifier: ^0.2.0
|
specifier: ^0.2.0
|
||||||
version: 0.2.0
|
version: 0.2.0
|
||||||
'@serve.zone/interfaces':
|
'@serve.zone/interfaces':
|
||||||
specifier: ^6.1.0
|
specifier: ^6.2.0
|
||||||
version: 6.1.0
|
version: 6.2.0
|
||||||
'@tsclass/tsclass':
|
'@tsclass/tsclass':
|
||||||
specifier: ^9.5.1
|
specifier: ^9.5.1
|
||||||
version: 9.5.1
|
version: 9.5.1
|
||||||
@@ -1838,8 +1838,8 @@ packages:
|
|||||||
'@serve.zone/interfaces@5.10.0':
|
'@serve.zone/interfaces@5.10.0':
|
||||||
resolution: {integrity: sha512-8ZnP1A43UZlYwfd2j+S0Yin//didacIX2Rou9MobRuSFFgi1RQOqQcIWqOINcDk80wBDuYkyMCwHygYxD5i+Ig==}
|
resolution: {integrity: sha512-8ZnP1A43UZlYwfd2j+S0Yin//didacIX2Rou9MobRuSFFgi1RQOqQcIWqOINcDk80wBDuYkyMCwHygYxD5i+Ig==}
|
||||||
|
|
||||||
'@serve.zone/interfaces@6.1.0':
|
'@serve.zone/interfaces@6.2.0':
|
||||||
resolution: {integrity: sha512-nhxMmMfemBaGM1xxFpbNM8/zPM4Y59mVsgz9XBNGZr6n7kn81QsY+Xcn5HnLywztuGHqgEZRWGmI4MPzORRktw==}
|
resolution: {integrity: sha512-7eZIdl0IcuiUReGetJnOFkewCWBTEVGJSyUHdQkjtr0FLfgyqgm4ItlJlWPVpFlapm6GxkHYmPBkwxrpOq1Bsw==}
|
||||||
|
|
||||||
'@shikijs/engine-oniguruma@3.23.0':
|
'@shikijs/engine-oniguruma@3.23.0':
|
||||||
resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
|
resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
|
||||||
@@ -7364,7 +7364,7 @@ snapshots:
|
|||||||
|
|
||||||
'@serve.zone/appstore@0.2.0':
|
'@serve.zone/appstore@0.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@serve.zone/interfaces': 6.1.0
|
'@serve.zone/interfaces': 6.2.0
|
||||||
|
|
||||||
'@serve.zone/interfaces@5.10.0':
|
'@serve.zone/interfaces@5.10.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -7372,7 +7372,7 @@ snapshots:
|
|||||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||||
'@tsclass/tsclass': 9.5.1
|
'@tsclass/tsclass': 9.5.1
|
||||||
|
|
||||||
'@serve.zone/interfaces@6.1.0':
|
'@serve.zone/interfaces@6.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest-interfaces': 3.0.19
|
'@api.global/typedrequest-interfaces': 3.0.19
|
||||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||||
|
|||||||
@@ -16,6 +16,28 @@ const logErrorDetails = (errorArg: unknown) => {
|
|||||||
console.error(` - Error:`, errorArg);
|
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 () => {
|
tap.preTask('should start cloudly', async () => {
|
||||||
testCloudly = await helpers.createCloudly();
|
testCloudly = await helpers.createCloudly();
|
||||||
await testCloudly.start();
|
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 () => {
|
tap.test('should create and consume node jump codes', async () => {
|
||||||
const cluster = await testClient.cluster.createCluster('Jump Code Test Cluster');
|
const cluster = await testClient.cluster.createCluster('Jump Code Test Cluster');
|
||||||
const createJumpCommandTR = testClient.typedsocketClient.createTypedRequest<any>('createNodeJumpCommand');
|
const createJumpCommandTR = testClient.typedsocketClient.createTypedRequest<any>('createNodeJumpCommand');
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ type IHostedAppLifecycleState = plugins.servezoneInterfaces.data.IHostedAppLifec
|
|||||||
type IHostedAppUpgradeState = plugins.servezoneInterfaces.data.IHostedAppUpgradeState;
|
type IHostedAppUpgradeState = plugins.servezoneInterfaces.data.IHostedAppUpgradeState;
|
||||||
type IHostedAppRuntimeIdentity = plugins.servezoneInterfaces.data.IHostedAppRuntimeIdentity;
|
type IHostedAppRuntimeIdentity = plugins.servezoneInterfaces.data.IHostedAppRuntimeIdentity;
|
||||||
|
|
||||||
|
interface IHostedAppParentUpgradeResponse {
|
||||||
|
isHosted: boolean;
|
||||||
|
unavailableReason?: string;
|
||||||
|
upgradeState: IHostedAppUpgradeState;
|
||||||
|
}
|
||||||
|
|
||||||
type TExtendedServiceData = plugins.servezoneInterfaces.data.IService['data'] & {
|
type TExtendedServiceData = plugins.servezoneInterfaces.data.IService['data'] & {
|
||||||
hostedAppLifecycle?: IHostedAppLifecycleState;
|
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<{
|
public async requestParentInitialAdminBootstrap(): Promise<{
|
||||||
username: string;
|
username: string;
|
||||||
password: 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,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,13 @@ export interface IAppStoreState {
|
|||||||
upgradeOperations: IAppStoreUpgradeOperation[];
|
upgradeOperations: IAppStoreUpgradeOperation[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IHostedRuntimeState {
|
||||||
|
isHosted: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
unavailableReason?: string;
|
||||||
|
upgradeState: plugins.interfaces.data.IHostedAppUpgradeState | null;
|
||||||
|
}
|
||||||
|
|
||||||
const emptyDataState: IDataState = {
|
const emptyDataState: IDataState = {
|
||||||
secretGroups: [],
|
secretGroups: [],
|
||||||
secretBundles: [],
|
secretBundles: [],
|
||||||
@@ -117,6 +124,12 @@ const emptyAppStoreState: IAppStoreState = {
|
|||||||
upgradeOperations: [],
|
upgradeOperations: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emptyHostedRuntimeState: IHostedRuntimeState = {
|
||||||
|
isHosted: false,
|
||||||
|
loading: false,
|
||||||
|
upgradeState: null,
|
||||||
|
};
|
||||||
|
|
||||||
interface IReq_AdminValidateIdentity {
|
interface IReq_AdminValidateIdentity {
|
||||||
method: 'adminValidateIdentity';
|
method: 'adminValidateIdentity';
|
||||||
request: {
|
request: {
|
||||||
@@ -183,6 +196,8 @@ export const logoutAction = loginStatePart.createAction(async (statePartArg) =>
|
|||||||
apiClient.identity = null;
|
apiClient.identity = null;
|
||||||
dataState.setState({ ...emptyDataState });
|
dataState.setState({ ...emptyDataState });
|
||||||
appStoreStatePart.setState({ ...emptyAppStoreState });
|
appStoreStatePart.setState({ ...emptyAppStoreState });
|
||||||
|
hostedRuntimeStatePart.setState({ ...emptyHostedRuntimeState });
|
||||||
|
clearHostedRuntimeUpgradePoll();
|
||||||
} catch {}
|
} catch {}
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
@@ -202,6 +217,12 @@ export const appStoreStatePart = await appstate.getStatePart<IAppStoreState>(
|
|||||||
'soft',
|
'soft',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const hostedRuntimeStatePart = await appstate.getStatePart<IHostedRuntimeState>(
|
||||||
|
'hostedRuntime',
|
||||||
|
{ ...emptyHostedRuntimeState },
|
||||||
|
'soft',
|
||||||
|
);
|
||||||
|
|
||||||
// Shared API client instance (used by UI actions)
|
// Shared API client instance (used by UI actions)
|
||||||
type TCloudlyApiClientWithNullableIdentity = Omit<plugins.servezoneApi.CloudlyApiClient, 'identity'> & {
|
type TCloudlyApiClientWithNullableIdentity = Omit<plugins.servezoneApi.CloudlyApiClient, 'identity'> & {
|
||||||
identity: plugins.interfaces.data.IIdentity | null;
|
identity: plugins.interfaces.data.IIdentity | null;
|
||||||
@@ -303,6 +324,8 @@ export const invalidateIdentity = async (reasonArg = 'identity is not valid'): P
|
|||||||
});
|
});
|
||||||
dataState.setState({ ...emptyDataState });
|
dataState.setState({ ...emptyDataState });
|
||||||
appStoreStatePart.setState({ ...emptyAppStoreState });
|
appStoreStatePart.setState({ ...emptyAppStoreState });
|
||||||
|
hostedRuntimeStatePart.setState({ ...emptyHostedRuntimeState });
|
||||||
|
clearHostedRuntimeUpgradePoll();
|
||||||
} finally {
|
} finally {
|
||||||
identityInvalidationRunning = false;
|
identityInvalidationRunning = false;
|
||||||
}
|
}
|
||||||
@@ -865,6 +888,91 @@ const getIdentityForRequest = () => {
|
|||||||
return identity;
|
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(
|
export const fetchAppStoreTemplatesAction = appStoreStatePart.createAction(
|
||||||
async (statePartArg) => {
|
async (statePartArg) => {
|
||||||
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreTemplates');
|
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreTemplates');
|
||||||
|
|||||||
@@ -23,8 +23,29 @@ export class CloudlyViewSettings extends DeesElement {
|
|||||||
@state()
|
@state()
|
||||||
private accessor testResults: {[key: string]: {success: boolean; message: string}} = {};
|
private accessor testResults: {[key: string]: {success: boolean; message: string}} = {};
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor hostedRuntime: appstate.IHostedRuntimeState = {
|
||||||
|
isHosted: false,
|
||||||
|
loading: false,
|
||||||
|
upgradeState: null,
|
||||||
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
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();
|
this.loadSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,10 +62,24 @@ export class CloudlyViewSettings extends DeesElement {
|
|||||||
dees-panel { margin-bottom: 16px; }
|
dees-panel { margin-bottom: 16px; }
|
||||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
.form-grid.single { grid-template-columns: 1fr; }
|
.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) { .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() {
|
private async loadSettings() {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
try {
|
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>`;
|
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() {
|
public render() {
|
||||||
if (this.isLoading && Object.keys(this.settings).length === 0) {
|
if (this.isLoading && Object.keys(this.settings).length === 0) {
|
||||||
return html`<div class="loading-container"><dees-spinner></dees-spinner></div>`;
|
return html`<div class="loading-container"><dees-spinner></dees-spinner></div>`;
|
||||||
@@ -109,6 +262,7 @@ export class CloudlyViewSettings extends DeesElement {
|
|||||||
return html`
|
return html`
|
||||||
<cloudly-sectionheading>Settings</cloudly-sectionheading>
|
<cloudly-sectionheading>Settings</cloudly-sectionheading>
|
||||||
<div class="settings-container">
|
<div class="settings-container">
|
||||||
|
${this.renderHostedRuntimePanel()}
|
||||||
<dees-form @formData=${(e: CustomEvent) => { this.saveSettings((e.detail as any).data); }}>
|
<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'}>
|
<dees-panel .title=${'Hetzner Cloud'} .subtitle=${'Configure Hetzner Cloud API access'} .variant=${'outline'}>
|
||||||
<div class="test-status">
|
<div class="test-status">
|
||||||
|
|||||||
Reference in New Issue
Block a user