mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Adding latest version section to the admin panel.
Summary: Update for the admin page to show the latest available version information. - Latest version is read from docs.getgrist.com by default - It sends basic information (installationId, deployment type, and version) - Checks are done only on the page itself - The actual request is routed through the API (to avoid CORS) Test Plan: Added new test Reviewers: paulfitz Reviewed By: paulfitz Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D4238
This commit is contained in:
parent
a3442aee77
commit
ecf242c6c6
@ -4,8 +4,14 @@ import moment from 'moment';
|
|||||||
* Given a UTC Date ISO 8601 string (the doc updatedAt string), gives a reader-friendly
|
* Given a UTC Date ISO 8601 string (the doc updatedAt string), gives a reader-friendly
|
||||||
* relative time to now - e.g. 'yesterday', '2 days ago'.
|
* relative time to now - e.g. 'yesterday', '2 days ago'.
|
||||||
*/
|
*/
|
||||||
export function getTimeFromNow(utcDateISO: string): string {
|
export function getTimeFromNow(utcDateISO: string): string
|
||||||
const time = moment.utc(utcDateISO);
|
/**
|
||||||
|
* Given a unix timestamp (in milliseconds), gives a reader-friendly
|
||||||
|
* relative time to now - e.g. 'yesterday', '2 days ago'.
|
||||||
|
*/
|
||||||
|
export function getTimeFromNow(ms: number): string
|
||||||
|
export function getTimeFromNow(isoOrTimestamp: string|number): string {
|
||||||
|
const time = moment.utc(isoOrTimestamp);
|
||||||
const now = moment();
|
const now = moment();
|
||||||
const diff = now.diff(time, 's');
|
const diff = now.diff(time, 's');
|
||||||
if (diff < 0 && diff > -60) {
|
if (diff < 0 && diff > -60) {
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import {getPageTitleSuffix} from 'app/common/gristUrls';
|
|
||||||
import {getGristConfig} from 'app/common/urlUtils';
|
|
||||||
import * as version from 'app/common/version';
|
|
||||||
import {buildHomeBanners} from 'app/client/components/Banners';
|
import {buildHomeBanners} from 'app/client/components/Banners';
|
||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import {AppModel} from 'app/client/models/AppModel';
|
import {localStorageJsonObs} from 'app/client/lib/localStorageObs';
|
||||||
|
import {getTimeFromNow} from 'app/client/lib/timeUtils';
|
||||||
|
import {AppModel, getHomeUrl} from 'app/client/models/AppModel';
|
||||||
import {urlState} from 'app/client/models/gristUrlState';
|
import {urlState} from 'app/client/models/gristUrlState';
|
||||||
import {AppHeader} from 'app/client/ui/AppHeader';
|
import {AppHeader} from 'app/client/ui/AppHeader';
|
||||||
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
|
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
|
||||||
@ -12,10 +11,19 @@ import {SupportGristPage} from 'app/client/ui/SupportGristPage';
|
|||||||
import {createTopBarHome} from 'app/client/ui/TopBar';
|
import {createTopBarHome} from 'app/client/ui/TopBar';
|
||||||
import {transition} from 'app/client/ui/transitions';
|
import {transition} from 'app/client/ui/transitions';
|
||||||
import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs';
|
import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs';
|
||||||
|
import {basicButton} from 'app/client/ui2018/buttons';
|
||||||
|
import {toggle} from 'app/client/ui2018/checkbox';
|
||||||
import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
|
import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {cssLink} from 'app/client/ui2018/links';
|
import {cssLink, makeLinks} from 'app/client/ui2018/links';
|
||||||
import {Disposable, dom, DomContents, IDisposableOwner, Observable, styled} from 'grainjs';
|
import {getPageTitleSuffix} from 'app/common/gristUrls';
|
||||||
|
import {InstallAPI, InstallAPIImpl, LatestVersion} from 'app/common/InstallAPI';
|
||||||
|
import {naturalCompare} from 'app/common/SortFunc';
|
||||||
|
import {getGristConfig} from 'app/common/urlUtils';
|
||||||
|
import * as version from 'app/common/version';
|
||||||
|
import {Computed, Disposable, dom, DomContents, IDisposable,
|
||||||
|
IDisposableOwner, MultiHolder, Observable, styled} from 'grainjs';
|
||||||
|
|
||||||
|
|
||||||
const t = makeT('AdminPanel');
|
const t = makeT('AdminPanel');
|
||||||
|
|
||||||
@ -26,6 +34,7 @@ export function getAdminPanelName() {
|
|||||||
|
|
||||||
export class AdminPanel extends Disposable {
|
export class AdminPanel extends Disposable {
|
||||||
private _supportGrist = SupportGristPage.create(this, this._appModel);
|
private _supportGrist = SupportGristPage.create(this, this._appModel);
|
||||||
|
private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl());
|
||||||
|
|
||||||
constructor(private _appModel: AppModel) {
|
constructor(private _appModel: AppModel) {
|
||||||
super();
|
super();
|
||||||
@ -62,7 +71,7 @@ export class AdminPanel extends Disposable {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _buildMainContent(owner: IDisposableOwner) {
|
private _buildMainContent(owner: MultiHolder) {
|
||||||
return cssPageContainer(
|
return cssPageContainer(
|
||||||
dom.cls('clipboard'),
|
dom.cls('clipboard'),
|
||||||
{tabIndex: "-1"},
|
{tabIndex: "-1"},
|
||||||
@ -91,6 +100,7 @@ export class AdminPanel extends Disposable {
|
|||||||
description: t('Current version of Grist'),
|
description: t('Current version of Grist'),
|
||||||
value: cssValueLabel(`Version ${version.version}`),
|
value: cssValueLabel(`Version ${version.version}`),
|
||||||
}),
|
}),
|
||||||
|
this._buildUpdates(owner),
|
||||||
),
|
),
|
||||||
testId('admin-panel'),
|
testId('admin-panel'),
|
||||||
);
|
);
|
||||||
@ -138,18 +148,229 @@ export class AdminPanel extends Disposable {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _buildUpdates(owner: MultiHolder) {
|
||||||
|
// We can be in those states:
|
||||||
|
enum State {
|
||||||
|
// Never checked before (no last version or last check time).
|
||||||
|
// Shows "No information available" [Check now]
|
||||||
|
NEVER,
|
||||||
|
// Did check previously, but it was a while ago, user should press the button to check.
|
||||||
|
// Shows "Last checked X days ago" [Check now]
|
||||||
|
STALE,
|
||||||
|
// In the middle of checking for updates.
|
||||||
|
CHECKING,
|
||||||
|
// Transient state, shown after Check now is clicked.
|
||||||
|
// Grist is up to date (state only shown after a successful check), or even upfront.
|
||||||
|
// Won't be shown after page is reloaded.
|
||||||
|
// Shows "Checking for updates..."
|
||||||
|
CURRENT,
|
||||||
|
// A newer version is available. Can be shown after reload if last
|
||||||
|
// version that was checked is newer than the current version.
|
||||||
|
// Shows "Newer version available" [version]
|
||||||
|
AVAILABLE,
|
||||||
|
// Error occurred during this check. If the error occurred during last check
|
||||||
|
// it is not stored.
|
||||||
|
// Shows "Error checking for updates" [Check now]
|
||||||
|
ERROR,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Are updates enabled at all.
|
||||||
|
const defaultValue = {
|
||||||
|
onLoad: false,
|
||||||
|
lastCheckDate: null as number|null,
|
||||||
|
lastVersion: null as string|null,
|
||||||
|
};
|
||||||
|
const prop = <T extends keyof typeof defaultValue>(key: T) => {
|
||||||
|
const computed = Computed.create(owner, (use) => use(settings)[key]);
|
||||||
|
computed.onWrite((val) => settings.set({...settings.get(), [key]: val}));
|
||||||
|
return computed as Observable<typeof defaultValue[T]>;
|
||||||
|
};
|
||||||
|
const settings = owner.autoDispose(localStorageJsonObs('new-version-check', defaultValue));
|
||||||
|
const onLoad = prop('onLoad');
|
||||||
|
const latestVersion = prop('lastVersion');
|
||||||
|
const lastCheckDate = prop('lastCheckDate');
|
||||||
|
const comparison = Computed.create(owner, (use) => {
|
||||||
|
const versions = [version.version, use(latestVersion)];
|
||||||
|
if (!versions[1]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Sort them in natural order, so that "1.10" comes after "1.9".
|
||||||
|
versions.sort(naturalCompare).reverse();
|
||||||
|
if (versions[0] === version.version) {
|
||||||
|
return 'old';
|
||||||
|
} else {
|
||||||
|
return 'new';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Observable state of the updates check.
|
||||||
|
const state: Observable<State> = Observable.create(owner, State.NEVER);
|
||||||
|
|
||||||
|
// The background task that checks for updates, can be disposed (cancelled) when needed.
|
||||||
|
let backgroundTask: IDisposable|null = null;
|
||||||
|
|
||||||
|
// By default we link to the GitHub releases page, but the endpoint might say something different.
|
||||||
|
let releaseURL = 'https://github.com/gristlabs/grist-core/releases';
|
||||||
|
|
||||||
|
// All the events that might occur
|
||||||
|
const actions = {
|
||||||
|
checkForUpdates: async () => {
|
||||||
|
state.set(State.CHECKING);
|
||||||
|
latestVersion.set(null);
|
||||||
|
// We can be disabled, why the check is in progress.
|
||||||
|
const controller = new AbortController();
|
||||||
|
backgroundTask = {
|
||||||
|
dispose() {
|
||||||
|
if (controller.signal.aborted) { return; }
|
||||||
|
backgroundTask = null;
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
owner.autoDispose(backgroundTask);
|
||||||
|
try {
|
||||||
|
const result = await this._installAPI.checkUpdates();
|
||||||
|
if (controller.signal.aborted) { return; }
|
||||||
|
actions.gotLatestVersion(result);
|
||||||
|
} catch(err) {
|
||||||
|
if (controller.signal.aborted) { return; }
|
||||||
|
state.set(State.ERROR);
|
||||||
|
reportError(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
disableAutoCheck: () => {
|
||||||
|
backgroundTask?.dispose();
|
||||||
|
backgroundTask = null;
|
||||||
|
onLoad.set(false);
|
||||||
|
},
|
||||||
|
enableAutoCheck: () => {
|
||||||
|
onLoad.set(true);
|
||||||
|
if (state.get() !== State.CHECKING && state.get() !== State.AVAILABLE) {
|
||||||
|
actions.checkForUpdates().catch(reportError);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
gotLatestVersion: (data: LatestVersion) => {
|
||||||
|
lastCheckDate.set(Date.now());
|
||||||
|
latestVersion.set(data.latestVersion);
|
||||||
|
releaseURL = data.updateURL || releaseURL;
|
||||||
|
const result = comparison.get();
|
||||||
|
switch (result) {
|
||||||
|
case 'old': state.set(State.CURRENT); break;
|
||||||
|
case 'new': state.set(State.AVAILABLE); break;
|
||||||
|
// This should not happen, but if it does, we should show the error.
|
||||||
|
default: state.set(State.ERROR); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const description = Computed.create(owner, (use) => {
|
||||||
|
switch (use(state)) {
|
||||||
|
case State.NEVER: return t('No information available');
|
||||||
|
case State.CHECKING: return '⌛ ' + t('Checking for updates...');
|
||||||
|
case State.CURRENT: return '✅ ' + t('Grist is up to date');
|
||||||
|
case State.AVAILABLE: return t('Newer version available');
|
||||||
|
case State.ERROR: return '❌ ' + t('Error checking for updates');
|
||||||
|
case State.STALE: {
|
||||||
|
const lastCheck = use(lastCheckDate);
|
||||||
|
return t('Last checked {{time}}', {time: lastCheck ? getTimeFromNow(lastCheck) : 'n/a'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now trigger the initial state, by checking if we should auto-check.
|
||||||
|
if (onLoad.get()) {
|
||||||
|
actions.checkForUpdates().catch(reportError);
|
||||||
|
} else {
|
||||||
|
if (comparison.get() === 'new') {
|
||||||
|
state.set(State.AVAILABLE);
|
||||||
|
} else if (comparison.get() === 'old') {
|
||||||
|
state.set(State.STALE);
|
||||||
|
} else {
|
||||||
|
state.set(State.NEVER); // default one.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle component operates on a boolean observable, without a way to set the value. So
|
||||||
|
// create a controller for it to intercept the write and call the appropriate action.
|
||||||
|
const enabledController = Computed.create(owner, (use) => use(onLoad));
|
||||||
|
enabledController.onWrite((val) => {
|
||||||
|
if (val) {
|
||||||
|
actions.enableAutoCheck();
|
||||||
|
} else {
|
||||||
|
actions.disableAutoCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const upperCheckNowVisible = Computed.create(owner, (use) => {
|
||||||
|
switch (use(state)) {
|
||||||
|
case State.CHECKING:
|
||||||
|
case State.CURRENT:
|
||||||
|
case State.AVAILABLE:
|
||||||
|
return false;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this._buildItem(owner, {
|
||||||
|
id: 'updates',
|
||||||
|
name: t('Updates'),
|
||||||
|
description: dom('span', testId('admin-panel-updates-message'), dom.text(description)),
|
||||||
|
value: cssValueButton(
|
||||||
|
dom.domComputed(use => {
|
||||||
|
if (use(state) === State.CHECKING) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (use(upperCheckNowVisible)) {
|
||||||
|
return basicButton(
|
||||||
|
t('Check now'),
|
||||||
|
dom.on('click', actions.checkForUpdates),
|
||||||
|
testId('admin-panel-updates-upper-check-now')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (use(latestVersion)) {
|
||||||
|
return cssValueLabel(`Version ${use(latestVersion)}`, testId('admin-panel-updates-version'));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Invalid state');
|
||||||
|
})
|
||||||
|
),
|
||||||
|
expandedContent: cssColumns(
|
||||||
|
cssColumn(
|
||||||
|
cssColumn.cls('-left'),
|
||||||
|
dom('div', t('Grist releases are at '), makeLinks(releaseURL)),
|
||||||
|
dom.maybe(lastCheckDate, ms => dom('div',
|
||||||
|
dom('span', t('Last checked {{time}}', {time: getTimeFromNow(ms)})),
|
||||||
|
dom('span', ' '),
|
||||||
|
// Format date in local format.
|
||||||
|
cssGrayed(new Date(ms).toLocaleString()),
|
||||||
|
)),
|
||||||
|
dom('div', t('Auto-check when this page loads')),
|
||||||
|
),
|
||||||
|
cssColumn(
|
||||||
|
cssColumn.cls('-right'),
|
||||||
|
// `Check now` button, only shown when auto checks are enabled and we are not in the
|
||||||
|
// middle of checking. Otherwise the button is shown in the summary row, and there is
|
||||||
|
// no need to duplicate it.
|
||||||
|
dom.maybe(use => !use(upperCheckNowVisible), () => [
|
||||||
|
cssCheckNowButton(
|
||||||
|
t('Check now'),
|
||||||
|
testId('admin-panel-updates-lower-check-now'),
|
||||||
|
dom.on('click', actions.checkForUpdates),
|
||||||
|
dom.prop('disabled', use => use(state) === State.CHECKING),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
toggle(enabledController, testId('admin-panel-updates-auto-check')),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function maybeSwitchToggle(value: Observable<boolean|null>): DomContents {
|
function maybeSwitchToggle(value: Observable<boolean|null>): DomContents {
|
||||||
return dom('div.widget_switch',
|
return toggle(value, dom.hide((use) => use(value) === null));
|
||||||
(elem) => elem.style.setProperty('--grist-actual-cell-color', theme.controlFg.toString()),
|
|
||||||
dom.hide((use) => use(value) === null),
|
|
||||||
dom.cls('switch_on', (use) => use(value) || false),
|
|
||||||
dom.cls('switch_transition', true),
|
|
||||||
dom.on('click', () => value.set(!value.get())),
|
|
||||||
dom('div.switch_slider'),
|
|
||||||
dom('div.switch_circle'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cssPageContainer = styled('div', `
|
const cssPageContainer = styled('div', `
|
||||||
@ -215,7 +436,7 @@ const cssItemName = styled('div', `
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: ${vars.largeFontSize};
|
font-size: ${vars.largeFontSize};
|
||||||
&:first-child {
|
&:first-child {
|
||||||
margin-left: 28px;
|
margin-left: 24px;
|
||||||
}
|
}
|
||||||
@media ${mediaSmall} {
|
@media ${mediaSmall} {
|
||||||
& {
|
& {
|
||||||
@ -267,4 +488,52 @@ const cssValueLabel = styled('div', `
|
|||||||
color: ${theme.text};
|
color: ${theme.text};
|
||||||
border: 1px solid ${theme.inputBorder};
|
border: 1px solid ${theme.inputBorder};
|
||||||
border-radius: ${vars.controlBorderRadius};
|
border-radius: ${vars.controlBorderRadius};
|
||||||
|
&-empty {
|
||||||
|
visibility: hidden;
|
||||||
|
content: " ";
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
// A wrapper for the version details panel. Shows two columns.
|
||||||
|
// First grows as needed, second shrinks as needed and is aligned to the bottom.
|
||||||
|
const cssColumns = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
& > div:first-child {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
& > div:last-child {
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssColumn = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
margin-block: 1px; /* otherwise toggle is squashed: TODO: -1px in toggle looks like a bug */
|
||||||
|
&-left {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
&-right {
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssValueButton = styled('div', `
|
||||||
|
height: 30px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssCheckNowButton = styled(basicButton, `
|
||||||
|
&-hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssGrayed = styled('span', `
|
||||||
|
color: ${theme.lightText};
|
||||||
`);
|
`);
|
||||||
|
@ -16,7 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {testId, theme} from 'app/client/ui2018/cssVars';
|
import {testId, theme} from 'app/client/ui2018/cssVars';
|
||||||
import {Computed, dom, DomArg, DomContents, Observable, styled} from 'grainjs';
|
import {Computed, dom, DomArg, DomContents, DomElementArg, Observable, styled} from 'grainjs';
|
||||||
|
|
||||||
export const cssLabel = styled('label', `
|
export const cssLabel = styled('label', `
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -194,6 +194,19 @@ export const cssRadioCheckboxOptions = styled('div', `
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
export function toggle(value: Observable<boolean|null>, ...domArgs: DomElementArg[]): DomContents {
|
||||||
|
return dom('div.widget_switch',
|
||||||
|
(elem) => elem.style.setProperty('--grist-actual-cell-color', theme.controlFg.toString()),
|
||||||
|
dom.hide((use) => use(value) === null),
|
||||||
|
dom.cls('switch_on', (use) => use(value) || false),
|
||||||
|
dom.cls('switch_transition', true),
|
||||||
|
dom.on('click', () => value.set(!value.get())),
|
||||||
|
dom('div.switch_slider'),
|
||||||
|
dom('div.switch_circle'),
|
||||||
|
...domArgs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// We need to reset top and left of ::before element, as it is wrongly set
|
// We need to reset top and left of ::before element, as it is wrongly set
|
||||||
// on the inline checkbox.
|
// on the inline checkbox.
|
||||||
// To simulate radio button behavior, we will block user input after option is selected, because
|
// To simulate radio button behavior, we will block user input after option is selected, because
|
||||||
|
@ -24,9 +24,38 @@ export interface PrefWithSource<T> {
|
|||||||
|
|
||||||
export type PrefSource = 'environment-variable' | 'preferences';
|
export type PrefSource = 'environment-variable' | 'preferences';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON returned to the client (exported for tests).
|
||||||
|
*/
|
||||||
|
export interface LatestVersion {
|
||||||
|
/**
|
||||||
|
* Latest version of core component of the client.
|
||||||
|
*/
|
||||||
|
latestVersion: string;
|
||||||
|
/**
|
||||||
|
* If there were any critical updates after client's version. Undefined if
|
||||||
|
* we don't know client version or couldn't figure this out for some other reason.
|
||||||
|
*/
|
||||||
|
isCritical?: boolean;
|
||||||
|
/**
|
||||||
|
* Url where the client can download the latest version (if applicable)
|
||||||
|
*/
|
||||||
|
updateURL?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the latest version was updated (in ISO format).
|
||||||
|
*/
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface InstallAPI {
|
export interface InstallAPI {
|
||||||
getInstallPrefs(): Promise<InstallPrefsWithSources>;
|
getInstallPrefs(): Promise<InstallPrefsWithSources>;
|
||||||
updateInstallPrefs(prefs: Partial<InstallPrefs>): Promise<void>;
|
updateInstallPrefs(prefs: Partial<InstallPrefs>): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Returns information about latest version of Grist
|
||||||
|
*/
|
||||||
|
checkUpdates(): Promise<LatestVersion>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InstallAPIImpl extends BaseAPI implements InstallAPI {
|
export class InstallAPIImpl extends BaseAPI implements InstallAPI {
|
||||||
@ -45,6 +74,10 @@ export class InstallAPIImpl extends BaseAPI implements InstallAPI {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public checkUpdates(): Promise<LatestVersion> {
|
||||||
|
return this.requestJson(`${this._url}/api/install/updates`, {method: 'GET'});
|
||||||
|
}
|
||||||
|
|
||||||
private get _url(): string {
|
private get _url(): string {
|
||||||
return addCurrentOrgToPath(this._homeUrl);
|
return addCurrentOrgToPath(this._homeUrl);
|
||||||
}
|
}
|
||||||
|
@ -105,6 +105,8 @@ export const commonUrls = {
|
|||||||
gristLabsWidgetRepository: 'https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json',
|
gristLabsWidgetRepository: 'https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json',
|
||||||
githubGristCore: 'https://github.com/gristlabs/grist-core',
|
githubGristCore: 'https://github.com/gristlabs/grist-core',
|
||||||
githubSponsorGristLabs: 'https://github.com/sponsors/gristlabs',
|
githubSponsorGristLabs: 'https://github.com/sponsors/gristlabs',
|
||||||
|
|
||||||
|
versionCheck: 'https://api.getgrist.com/api/version',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -2,7 +2,7 @@ import {ApiError} from 'app/common/ApiError';
|
|||||||
import {ICustomWidget} from 'app/common/CustomWidget';
|
import {ICustomWidget} from 'app/common/CustomWidget';
|
||||||
import {delay} from 'app/common/delay';
|
import {delay} from 'app/common/delay';
|
||||||
import {DocCreationInfo} from 'app/common/DocListAPI';
|
import {DocCreationInfo} from 'app/common/DocListAPI';
|
||||||
import {encodeUrl, getSlugIfNeeded, GristDeploymentType, GristDeploymentTypes,
|
import {commonUrls, encodeUrl, getSlugIfNeeded, GristDeploymentType, GristDeploymentTypes,
|
||||||
GristLoadConfig, IGristUrlState, isOrgInPathOnly, parseSubdomain,
|
GristLoadConfig, IGristUrlState, isOrgInPathOnly, parseSubdomain,
|
||||||
sanitizePathTail} from 'app/common/gristUrls';
|
sanitizePathTail} from 'app/common/gristUrls';
|
||||||
import {getOrgUrlInfo} from 'app/common/gristUrls';
|
import {getOrgUrlInfo} from 'app/common/gristUrls';
|
||||||
@ -1853,6 +1853,35 @@ export class FlexServer implements GristServer {
|
|||||||
|
|
||||||
return resp.status(200).send();
|
return resp.status(200).send();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// GET api/checkUpdates
|
||||||
|
// Retrieves the latest version of the client from Grist SAAS endpoint.
|
||||||
|
this.app.get('/api/install/updates', adminPageMiddleware, expressWrap(async (req, res) => {
|
||||||
|
// Prepare data for the telemetry that endpoint might expect.
|
||||||
|
const installationId = (await this.getActivations().current()).id;
|
||||||
|
const deploymentType = this.getDeploymentType();
|
||||||
|
const currentVersion = version.version;
|
||||||
|
const response = await fetch(process.env.GRIST_TEST_VERSION_CHECK_URL || commonUrls.versionCheck, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
installationId,
|
||||||
|
deploymentType,
|
||||||
|
currentVersion,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
res.status(response.status);
|
||||||
|
if (response.headers.get('content-type')?.includes('application/json')) {
|
||||||
|
const data = await response.json();
|
||||||
|
res.json(data);
|
||||||
|
} else {
|
||||||
|
res.send(await response.text());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.json(await response.json());
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the HTML template sent for document pages.
|
// Get the HTML template sent for document pages.
|
||||||
|
@ -1,14 +1,16 @@
|
|||||||
import { ApiError } from "app/common/ApiError";
|
import { ApiError } from "app/common/ApiError";
|
||||||
import { MapWithTTL } from "app/common/AsyncCreate";
|
import { MapWithTTL } from "app/common/AsyncCreate";
|
||||||
import { GristDeploymentType } from "app/common/gristUrls";
|
import { GristDeploymentType } from "app/common/gristUrls";
|
||||||
|
import { LatestVersion } from 'app/common/InstallAPI';
|
||||||
import { naturalCompare } from "app/common/SortFunc";
|
import { naturalCompare } from "app/common/SortFunc";
|
||||||
import { RequestWithLogin } from "app/server/lib/Authorizer";
|
import { RequestWithLogin } from "app/server/lib/Authorizer";
|
||||||
|
import { expressWrap } from 'app/server/lib/expressWrap';
|
||||||
import { GristServer } from "app/server/lib/GristServer";
|
import { GristServer } from "app/server/lib/GristServer";
|
||||||
import { optIntegerParam, optStringParam } from "app/server/lib/requestUtils";
|
import { optIntegerParam, optStringParam } from "app/server/lib/requestUtils";
|
||||||
|
import { rateLimit } from 'express-rate-limit';
|
||||||
import { AbortController, AbortSignal } from 'node-abort-controller';
|
import { AbortController, AbortSignal } from 'node-abort-controller';
|
||||||
import type * as express from "express";
|
import type * as express from "express";
|
||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
import {expressWrap} from 'app/server/lib/expressWrap';
|
|
||||||
|
|
||||||
|
|
||||||
// URL to show to the client where the new version for docker based deployments can be found.
|
// URL to show to the client where the new version for docker based deployments can be found.
|
||||||
@ -67,8 +69,18 @@ export class UpdateManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rate limit the requests to the version API, so that we don't get spammed.
|
||||||
|
// 30 requests per second, per IP. The requests are cached so, we should be fine, but make
|
||||||
|
// sure it doesn't get out of hand. On dev laptop I could go up to 600 requests per second.
|
||||||
|
// (30 was picked by hand, to not hit the limit during tests).
|
||||||
|
const limiter = rateLimit({
|
||||||
|
windowMs: 1000,
|
||||||
|
limit: 30,
|
||||||
|
legacyHeaders: true,
|
||||||
|
});
|
||||||
|
|
||||||
// Support both POST and GET requests.
|
// Support both POST and GET requests.
|
||||||
this._app.use("/api/version", expressWrap(async (req, res) => {
|
this._app.use("/api/version", limiter, expressWrap(async (req, res) => {
|
||||||
// Get some telemetry from the body request.
|
// Get some telemetry from the body request.
|
||||||
const payload = (name: string) => req.body?.[name] ?? req.query[name];
|
const payload = (name: string) => req.body?.[name] ?? req.query[name];
|
||||||
|
|
||||||
@ -132,29 +144,6 @@ export class UpdateManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON returned to the client (exported for tests).
|
|
||||||
*/
|
|
||||||
export interface LatestVersion {
|
|
||||||
/**
|
|
||||||
* Latest version of core component of the client.
|
|
||||||
*/
|
|
||||||
latestVersion: string;
|
|
||||||
/**
|
|
||||||
* If there were any critical updates after client's version. Undefined if
|
|
||||||
* we don't know client version or couldn't figure this out for some other reason.
|
|
||||||
*/
|
|
||||||
isCritical?: boolean;
|
|
||||||
/**
|
|
||||||
* Url where the client can download the latest version (if applicable)
|
|
||||||
*/
|
|
||||||
updateURL?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When the latest version was updated (in ISO format).
|
|
||||||
*/
|
|
||||||
updatedAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type VersionChecker = (signal: AbortSignal) => Promise<LatestVersion>;
|
type VersionChecker = (signal: AbortSignal) => Promise<LatestVersion>;
|
||||||
|
|
||||||
|
@ -4,10 +4,11 @@ import * as sinon from 'sinon';
|
|||||||
|
|
||||||
import { configForUser } from "test/gen-server/testUtils";
|
import { configForUser } from "test/gen-server/testUtils";
|
||||||
import * as testUtils from "test/server/testUtils";
|
import * as testUtils from "test/server/testUtils";
|
||||||
import { serveSomething, Serving } from "test/server/customUtil";
|
import { Defer, serveSomething, Serving } from "test/server/customUtil";
|
||||||
import { Deps, LatestVersion } from "app/server/lib/UpdateManager";
|
import { Deps } from "app/server/lib/UpdateManager";
|
||||||
import { TestServer } from "test/gen-server/apiUtils";
|
import { TestServer } from "test/gen-server/apiUtils";
|
||||||
import { delay } from "app/common/delay";
|
import { delay } from "app/common/delay";
|
||||||
|
import { LatestVersion } from 'app/common/InstallAPI';
|
||||||
|
|
||||||
const assert = chai.assert;
|
const assert = chai.assert;
|
||||||
|
|
||||||
@ -249,7 +250,7 @@ async function dummyDockerHub() {
|
|||||||
|
|
||||||
return Object.assign(tempServer, {
|
return Object.assign(tempServer, {
|
||||||
signal() {
|
signal() {
|
||||||
const p = defer();
|
const p = new Defer();
|
||||||
signals.push(p);
|
signals.push(p);
|
||||||
return p;
|
return p;
|
||||||
},
|
},
|
||||||
@ -326,22 +327,3 @@ const FIRST_PAGE = (tempServer: Serving) => ({
|
|||||||
next: tempServer.url + "/next",
|
next: tempServer.url + "/next",
|
||||||
});
|
});
|
||||||
|
|
||||||
interface Defer {
|
|
||||||
then: Promise<void>["then"];
|
|
||||||
resolve: () => void;
|
|
||||||
reject: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defer = () => {
|
|
||||||
let resolve: () => void;
|
|
||||||
let reject: () => void;
|
|
||||||
const promise = new Promise<void>((res, rej) => {
|
|
||||||
resolve = res;
|
|
||||||
reject = rej;
|
|
||||||
}).catch(() => {});
|
|
||||||
return {
|
|
||||||
then: promise.then.bind(promise),
|
|
||||||
resolve: resolve!,
|
|
||||||
reject: reject!,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
@ -2,14 +2,17 @@ import {TelemetryLevel} from 'app/common/Telemetry';
|
|||||||
import {assert, driver, Key, WebElement} from 'mocha-webdriver';
|
import {assert, driver, Key, WebElement} from 'mocha-webdriver';
|
||||||
import * as gu from 'test/nbrowser/gristUtils';
|
import * as gu from 'test/nbrowser/gristUtils';
|
||||||
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
|
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
|
||||||
|
import {Defer, serveSomething, Serving} from 'test/server/customUtil';
|
||||||
import * as testUtils from 'test/server/testUtils';
|
import * as testUtils from 'test/server/testUtils';
|
||||||
|
import express from 'express';
|
||||||
|
|
||||||
describe('AdminPanel', function() {
|
describe('AdminPanel', function() {
|
||||||
this.timeout(30000);
|
this.timeout(300000);
|
||||||
setupTestSuite();
|
setupTestSuite();
|
||||||
|
|
||||||
let oldEnv: testUtils.EnvironmentSnapshot;
|
let oldEnv: testUtils.EnvironmentSnapshot;
|
||||||
let session: gu.Session;
|
let session: gu.Session;
|
||||||
|
let fakeServer: FakeUpdateServer;
|
||||||
|
|
||||||
afterEach(() => gu.checkForErrors());
|
afterEach(() => gu.checkForErrors());
|
||||||
|
|
||||||
@ -17,10 +20,13 @@ describe('AdminPanel', function() {
|
|||||||
oldEnv = new testUtils.EnvironmentSnapshot();
|
oldEnv = new testUtils.EnvironmentSnapshot();
|
||||||
process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = 'core';
|
process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = 'core';
|
||||||
process.env.GRIST_DEFAULT_EMAIL = gu.session().email;
|
process.env.GRIST_DEFAULT_EMAIL = gu.session().email;
|
||||||
|
fakeServer = await startFakeServer();
|
||||||
|
process.env.GRIST_TEST_VERSION_CHECK_URL = `${fakeServer.url()}/version`;
|
||||||
await server.restart(true);
|
await server.restart(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
after(async function() {
|
after(async function() {
|
||||||
|
await fakeServer.close();
|
||||||
oldEnv.restore();
|
oldEnv.restore();
|
||||||
await server.restart(true);
|
await server.restart(true);
|
||||||
});
|
});
|
||||||
@ -170,6 +176,128 @@ describe('AdminPanel', function() {
|
|||||||
assert.equal(await driver.find('.test-admin-panel-item-version').isDisplayed(), true);
|
assert.equal(await driver.find('.test-admin-panel-item-version').isDisplayed(), true);
|
||||||
assert.match(await driver.find('.test-admin-panel-item-value-version').getText(), /^Version \d+\./);
|
assert.match(await driver.find('.test-admin-panel-item-value-version').getText(), /^Version \d+\./);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const upperCheckNow = () => driver.find('.test-admin-panel-updates-upper-check-now');
|
||||||
|
const lowerCheckNow = () => driver.find('.test-admin-panel-updates-lower-check-now');
|
||||||
|
const autoCheckToggle = () => driver.find('.test-admin-panel-updates-auto-check');
|
||||||
|
const updateMessage = () => driver.find('.test-admin-panel-updates-message');
|
||||||
|
const versionBox = () => driver.find('.test-admin-panel-updates-version');
|
||||||
|
function waitForStatus(message: RegExp) {
|
||||||
|
return gu.waitToPass(async () => {
|
||||||
|
assert.match(await updateMessage().getText(), message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should check for updates', async function() {
|
||||||
|
// Clear any cached settings.
|
||||||
|
await driver.executeScript('window.sessionStorage.clear(); window.localStorage.clear();');
|
||||||
|
await driver.navigate().refresh();
|
||||||
|
await waitForAdminPanel();
|
||||||
|
|
||||||
|
// By default don't have any info.
|
||||||
|
await waitForStatus(/No information available/);
|
||||||
|
|
||||||
|
// We see upper check-now button.
|
||||||
|
assert.isTrue(await upperCheckNow().isDisplayed());
|
||||||
|
|
||||||
|
// We can expand.
|
||||||
|
await toggleItem('updates');
|
||||||
|
|
||||||
|
// We see a toggle to update automatically.
|
||||||
|
assert.isTrue(await autoCheckToggle().isDisplayed());
|
||||||
|
assert.isFalse(await isSwitchOn(autoCheckToggle()));
|
||||||
|
|
||||||
|
// We can click it, Grist will turn on auto checks and do it right away.
|
||||||
|
fakeServer.pause();
|
||||||
|
await autoCheckToggle().click();
|
||||||
|
assert.isTrue(await isSwitchOn(autoCheckToggle()));
|
||||||
|
|
||||||
|
// It will first show "Checking for updates" message.
|
||||||
|
// (Request is blocked by fake server, so it will not complete until we resume it.)
|
||||||
|
await waitForStatus(/Checking for updates/);
|
||||||
|
|
||||||
|
// Upper check now button is removed.
|
||||||
|
assert.isFalse(await upperCheckNow().isPresent());
|
||||||
|
|
||||||
|
// Resume server and respond.
|
||||||
|
fakeServer.resume();
|
||||||
|
|
||||||
|
// It will show "New version available" message.
|
||||||
|
await waitForStatus(/Newer version available/);
|
||||||
|
// And a version number.
|
||||||
|
assert.isTrue(await versionBox().isDisplayed());
|
||||||
|
assert.match(await versionBox().getText(), /Version 9\.9\.9/);
|
||||||
|
|
||||||
|
// When we reload, we will auto check for updates.
|
||||||
|
fakeServer.pause();
|
||||||
|
fakeServer.latestVersion = await currentVersion();
|
||||||
|
await driver.navigate().refresh();
|
||||||
|
await waitForAdminPanel();
|
||||||
|
await waitForStatus(/Checking for updates/);
|
||||||
|
fakeServer.resume();
|
||||||
|
await waitForStatus(/Grist is up to date/);
|
||||||
|
|
||||||
|
// Disable auto-checks.
|
||||||
|
await toggleItem('updates');
|
||||||
|
assert.isTrue(await isSwitchOn(autoCheckToggle()));
|
||||||
|
await autoCheckToggle().click();
|
||||||
|
assert.isFalse(await isSwitchOn(autoCheckToggle()));
|
||||||
|
// Nothing should happen.
|
||||||
|
await waitForStatus(/Grist is up to date/);
|
||||||
|
assert.isTrue(await versionBox().isDisplayed());
|
||||||
|
assert.equal(await versionBox().getText(), `Version ${await currentVersion()}`);
|
||||||
|
|
||||||
|
// Refresh to see if we are disabled.
|
||||||
|
fakeServer.pause();
|
||||||
|
await driver.navigate().refresh();
|
||||||
|
await waitForAdminPanel();
|
||||||
|
await waitForStatus(/Last checked .+ ago/);
|
||||||
|
fakeServer.resume();
|
||||||
|
// Expand and see if the toggle is off.
|
||||||
|
await toggleItem('updates');
|
||||||
|
assert.isFalse(await isSwitchOn(autoCheckToggle()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows up-to-date message', async function() {
|
||||||
|
fakeServer.latestVersion = await currentVersion();
|
||||||
|
// Click upper check now.
|
||||||
|
await waitForStatus(/Last checked .+ ago/);
|
||||||
|
await upperCheckNow().click();
|
||||||
|
await waitForStatus(/Grist is up to date/);
|
||||||
|
|
||||||
|
// Update version once again.
|
||||||
|
fakeServer.latestVersion = '9.9.10';
|
||||||
|
// Click lower check now.
|
||||||
|
fakeServer.pause();
|
||||||
|
await lowerCheckNow().click();
|
||||||
|
await waitForStatus(/Checking for updates/);
|
||||||
|
fakeServer.resume();
|
||||||
|
await waitForStatus(/Newer version available/);
|
||||||
|
|
||||||
|
// Make sure we see the new version.
|
||||||
|
assert.isTrue(await versionBox().isDisplayed());
|
||||||
|
assert.match(await versionBox().getText(), /Version 9\.9\.10/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error message', async function() {
|
||||||
|
fakeServer.failNext = true;
|
||||||
|
fakeServer.pause();
|
||||||
|
await lowerCheckNow().click();
|
||||||
|
await waitForStatus(/Checking for updates/);
|
||||||
|
fakeServer.resume();
|
||||||
|
await waitForStatus(/Error checking for updates/);
|
||||||
|
assert.match((await gu.getToasts())[0], /some error/);
|
||||||
|
await gu.wipeToasts();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send telemetry data', async function() {
|
||||||
|
assert.deepEqual({...fakeServer.payload, installationId: 'test'}, {
|
||||||
|
installationId: 'test',
|
||||||
|
deploymentType: 'core',
|
||||||
|
currentVersion: await currentVersion(),
|
||||||
|
});
|
||||||
|
assert.isNotEmpty(fakeServer.payload.installationId);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function assertTelemetryLevel(level: TelemetryLevel) {
|
async function assertTelemetryLevel(level: TelemetryLevel) {
|
||||||
@ -193,3 +321,64 @@ async function withExpandedItem(itemId: string, callback: () => Promise<void>) {
|
|||||||
|
|
||||||
const isSwitchOn = (switchElem: WebElement) => switchElem.matches('[class*=switch_on]');
|
const isSwitchOn = (switchElem: WebElement) => switchElem.matches('[class*=switch_on]');
|
||||||
const waitForAdminPanel = () => driver.findWait('.test-admin-panel', 2000);
|
const waitForAdminPanel = () => driver.findWait('.test-admin-panel', 2000);
|
||||||
|
|
||||||
|
interface FakeUpdateServer {
|
||||||
|
latestVersion: string;
|
||||||
|
failNext: boolean;
|
||||||
|
payload: any;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
pause: () => void;
|
||||||
|
resume: () => void;
|
||||||
|
url: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startFakeServer() {
|
||||||
|
let mutex: Defer|null = null;
|
||||||
|
const API: FakeUpdateServer = {
|
||||||
|
latestVersion: '9.9.9',
|
||||||
|
failNext: false,
|
||||||
|
payload: null,
|
||||||
|
close: async () => {
|
||||||
|
mutex?.resolve();
|
||||||
|
mutex = null;
|
||||||
|
await server?.shutdown();
|
||||||
|
server = null;
|
||||||
|
},
|
||||||
|
pause: () => {
|
||||||
|
mutex = new Defer();
|
||||||
|
},
|
||||||
|
resume: () => {
|
||||||
|
mutex?.resolve();
|
||||||
|
mutex = null;
|
||||||
|
},
|
||||||
|
url: () => {
|
||||||
|
return server!.url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let server: Serving|null = await serveSomething((app) => {
|
||||||
|
app.use(express.json());
|
||||||
|
app.post('/version', async (req, res, next) => {
|
||||||
|
API.payload = req.body;
|
||||||
|
try {
|
||||||
|
await mutex;
|
||||||
|
if (API.failNext) {
|
||||||
|
res.status(500).json({error: 'some error'});
|
||||||
|
API.failNext = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({latestVersion: API.latestVersion});
|
||||||
|
} catch(ex) {
|
||||||
|
next(ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return API;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function currentVersion() {
|
||||||
|
const currentVersionText = await driver.find(".test-admin-panel-item-value-version").getText();
|
||||||
|
const currentVersion = currentVersionText.match(/Version (.+)/)![1];
|
||||||
|
return currentVersion;
|
||||||
|
}
|
||||||
|
@ -64,3 +64,31 @@ export async function serveSomething(setup: (app: express.Express) => void, port
|
|||||||
const url = `http://localhost:${port}`;
|
const url = `http://localhost:${port}`;
|
||||||
return {url, shutdown};
|
return {url, shutdown};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a promise like object that can be resolved from outside.
|
||||||
|
*/
|
||||||
|
export class Defer {
|
||||||
|
private _resolve!: () => void;
|
||||||
|
private _reject!: (err: any) => void;
|
||||||
|
private _promise: Promise<void>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._promise = new Promise<void>((resolve, reject) => {
|
||||||
|
this._resolve = resolve;
|
||||||
|
this._reject = reject;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public get then() {
|
||||||
|
return this._promise.then.bind(this._promise);
|
||||||
|
}
|
||||||
|
|
||||||
|
public resolve() {
|
||||||
|
this._resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
public reject(err: any) {
|
||||||
|
this._reject(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user