feat(hostedapp): add hosted Cloudly parent upgrade controls

This commit is contained in:
2026-05-26 21:50:17 +00:00
parent c7a307c9d3
commit 26256c92bd
7 changed files with 434 additions and 7 deletions
+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');
+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">