Skip to content

Commit

Permalink
Improve handling of instance changes (#3768)
Browse files Browse the repository at this point in the history
* Improve handling of instance changes

* Updates to environment polling / endpoints

+ Tailor /xh/environment return based on auth user - support minimal unauthorized payload for backwards compat with existing usages, but mix-in full details for authenticated users.
+ Client now calls this endpoint both for initial load and for ongoing polling.
+ Rework `xhAppStatusCheck` to `xhEnvPollingConfig`, nest within authenticated /xh/environment return. Client reads and respects changes to config updates, with some sanity checks.
+ Restore /xh/version for backwards compat, return minimal environment summary.

* Additional tweaks

* Tweaks

* Fix regression

---------

Co-authored-by: Anselm McClain <atm@xh.io>
Co-authored-by: lbwexler <lbwexler@xh.io>
  • Loading branch information
3 people authored Sep 3, 2024
1 parent f4f1bf8 commit 44efc88
Showing 1 changed file with 84 additions and 63 deletions.
147 changes: 84 additions & 63 deletions svc/EnvironmentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,19 @@
import bpPkg from '@blueprintjs/core/package.json';
import {HoistService, XH} from '@xh/hoist/core';
import {agGridVersion} from '@xh/hoist/kit/ag-grid';
import {observable, action, makeObservable} from '@xh/hoist/mobx';
import {action, makeObservable, observable} from '@xh/hoist/mobx';
import hoistPkg from '@xh/hoist/package.json';
import {Timer} from '@xh/hoist/utils/async';
import {MINUTES, SECONDS} from '@xh/hoist/utils/datetime';
import {checkMaxVersion, checkMinVersion, deepFreeze} from '@xh/hoist/utils/js';
import {defaults} from 'lodash';
import {defaults, isFinite} from 'lodash';
import mobxPkg from 'mobx/package.json';
import {version as reactVersion} from 'react';

/**
* Load and report on the client and server environment, including software versions, timezones, and
* and other technical information.
*/
export class EnvironmentService extends HoistService {
static instance: EnvironmentService;

Expand All @@ -40,47 +44,49 @@ export class EnvironmentService extends HoistService {
@observable
serverInstance: string;

private _data = {};
private data = {};
private pollConfig: PollConfig;
private pollTimer: Timer;

override async initAsync() {
const serverEnv = await XH.fetchJson({url: 'xh/environment'}),
const {pollConfig, instanceName, ...serverEnv} = await XH.fetchJson({
url: 'xh/environment'
}),
clientTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'Unknown',
clientTimeZoneOffset = new Date().getTimezoneOffset() * -1 * MINUTES;

// Favor client-side data injected via Webpack build or otherwise determined locally,
// then apply all other env data sourced from the server.
this._data = defaults(
{
appCode: XH.appCode,
appName: XH.appName,
clientVersion: XH.appVersion,
clientBuild: XH.appBuild,
reactVersion,
hoistReactVersion: hoistPkg.version,
agGridVersion,
mobxVersion: mobxPkg.version,
blueprintCoreVersion: bpPkg.version,
clientTimeZone,
clientTimeZoneOffset
},
serverEnv
this.data = deepFreeze(
defaults(
{
appCode: XH.appCode,
appName: XH.appName,
clientVersion: XH.appVersion,
clientBuild: XH.appBuild,
reactVersion,
hoistReactVersion: hoistPkg.version,
agGridVersion,
mobxVersion: mobxPkg.version,
blueprintCoreVersion: bpPkg.version,
clientTimeZone,
clientTimeZoneOffset
},
serverEnv
)
);

// This bit is considered transient. Maintain in 'serverInstance' mutable property only
delete this._data['instanceName'];

deepFreeze(this._data);

this.setServerInfo(serverEnv.instanceName, serverEnv.appVersion, serverEnv.appBuild);
this.setServerInfo(instanceName, serverEnv.appVersion, serverEnv.appBuild);

this.pollConfig = pollConfig;
this.addReaction({
when: () => XH.appIsRunning,
run: this.startVersionChecking
run: this.startPolling
});
}

get(key: string): any {
return this._data[key];
return this.data[key];
}

get appEnvironment(): AppEnvironment {
Expand Down Expand Up @@ -111,54 +117,64 @@ export class EnvironmentService extends HoistService {
makeObservable(this);
}

private startVersionChecking() {
const interval = XH.getConf('xhAppVersionCheck', {})?.interval ?? -1;
Timer.create({
runFn: this.checkServerVersionAsync,
interval: interval * SECONDS
private startPolling() {
this.pollTimer = Timer.create({
runFn: () => this.pollServerAsync(),
interval: this.pollIntervalMs,
delay: true
});
}

private checkServerVersionAsync = async () => {
const data = await XH.fetchJson({url: 'xh/version'}),
{instanceName, appVersion, appBuild, mode} = data;

// Compare latest version/build info from server against the same info (also supplied by
// server) when the app initialized. A change indicates an update to the app and will
// force the user to refresh or prompt the user to refresh via the banner according to the
// `mode` set in `xhAppVersionCheck`. Builds are checked here to trigger refresh prompts
// across SNAPSHOT updates for projects with active dev/QA users.
if (appVersion !== this.get('appVersion') || appBuild !== this.get('appBuild')) {
if (mode === 'promptReload') {
XH.appContainerModel.showUpdateBanner(appVersion, appBuild);
} else if (mode === 'forceReload') {
XH.suspendApp({
reason: 'APP_UPDATE',
message: `A new version of ${XH.clientAppName} is now available (${appVersion}) and requires an immediate update.`
});
}
}

// Note that the case of version mismatches across the client and server we do *not* show
// the update bar to the user - that would indicate a deployment issue that a client reload
// is unlikely to resolve, leaving the user in a frustrating state where they are endlessly
// prompted to refresh.
const clientVersion = this.get('clientVersion');
if (appVersion !== clientVersion) {
this.logWarn(
`Version mismatch detected between client and server - ${clientVersion} vs ${appVersion}`
);
private async pollServerAsync() {
let data;
try {
data = await XH.fetchJson({url: 'xh/environmentPoll'});
} catch (e) {
this.logError('Error polling server environment', e);
return;
}

// Update config/interval, and server info
const {pollConfig, instanceName, appVersion, appBuild} = data;
this.pollConfig = pollConfig;
this.pollTimer.setInterval(this.pollIntervalMs);
this.setServerInfo(instanceName, appVersion, appBuild);
};

// Handle version change
if (appVersion != XH.getEnv('appVersion') || appBuild != XH.getEnv('appBuild')) {
// force the user to refresh or prompt the user to refresh via the banner according to config
// build checked to trigger refresh across SNAPSHOT updates in lower environments
const {onVersionChange} = pollConfig;
switch (onVersionChange) {
case 'promptReload':
XH.appContainerModel.showUpdateBanner(appVersion, appBuild);
return;
case 'forceReload':
XH.suspendApp({
reason: 'APP_UPDATE',
message: `A new version of ${XH.clientAppName} is now available (${appVersion}) and requires an immediate update.`
});
return;
default:
this.logWarn(
`New version ${appVersion} reported by server, onVersionChange is ${onVersionChange} - ignoring.`
);
}
}
}

@action
private setServerInfo(serverInstance, serverVersion, serverBuild) {
private setServerInfo(serverInstance: string, serverVersion: string, serverBuild: string) {
this.serverInstance = serverInstance;
this.serverVersion = serverVersion;
this.serverBuild = serverBuild;
}

private get pollIntervalMs(): number {
// Throttle to 5secs, disable if set to 0 or less.
const {interval} = this.pollConfig;
return isFinite(interval) && interval > 0 ? Math.max(interval, 5) * SECONDS : -1;
}
}

/**
Expand All @@ -175,3 +191,8 @@ export type AppEnvironment =
| 'Test'
| 'UAT'
| 'BCP';

interface PollConfig {
interval: number;
onVersionChange: 'forceReload' | 'promptReload' | 'silent';
}

0 comments on commit 44efc88

Please sign in to comment.