gristlabs_grist-core/app/client/ui/AdminPanel.ts
Paul Fitzpatrick d431c1eb63 (core) add a sandbox check to admin panel, and start reconciling boot and admin pages
Summary:
This adds a basic sandbox check to the admin panel. It also makes
the "probes" used in the boot page available from the admin panel,
though they are not yet displayed. The sandbox check is built as
a probe.

In the interests of time, a lot of steps had to be deferred:
 * Reconcile fully the admin panel and boot page. Specifically, the
   admin panel should be equally robust to common configuration problems.
 * Add tests for the sandbox check.
 * Generalize to multi-server setups. The read-out will not yet be useful
   for setups where doc workers and home servers are configured
   separately.

Test Plan: Added new test

Reviewers: jarek, georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D4241
2024-04-29 20:52:39 -04:00

601 lines
19 KiB
TypeScript

import {buildHomeBanners} from 'app/client/components/Banners';
import {makeT} from 'app/client/lib/localization';
import {localStorageJsonObs} from 'app/client/lib/localStorageObs';
import {getTimeFromNow} from 'app/client/lib/timeUtils';
import {AppModel, getHomeUrl, reportError} from 'app/client/models/AppModel';
import {AdminChecks} from 'app/client/models/AdminChecks';
import {urlState} from 'app/client/models/gristUrlState';
import {AppHeader} from 'app/client/ui/AppHeader';
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
import {pagePanels} from 'app/client/ui/PagePanels';
import {SupportGristPage} from 'app/client/ui/SupportGristPage';
import {createTopBarHome} from 'app/client/ui/TopBar';
import {transition} from 'app/client/ui/transitions';
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 {icon} from 'app/client/ui2018/icons';
import {cssLink, makeLinks} from 'app/client/ui2018/links';
import {SandboxingBootProbeDetails} from 'app/common/BootProbe';
import {commonUrls, 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');
// Translated "Admin Panel" name, made available to other modules.
export function getAdminPanelName() {
return t("Admin Panel");
}
export class AdminPanel extends Disposable {
private _supportGrist = SupportGristPage.create(this, this._appModel);
private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl());
private _checks: AdminChecks;
constructor(private _appModel: AppModel) {
super();
document.title = getAdminPanelName() + getPageTitleSuffix(getGristConfig());
this._checks = new AdminChecks(this);
}
public buildDom() {
this._checks.fetchAvailableChecks().catch(err => {
reportError(err);
});
const panelOpen = Observable.create(this, false);
return pagePanels({
leftPanel: {
panelWidth: Observable.create(this, 240),
panelOpen,
hideOpener: true,
header: dom.create(AppHeader, this._appModel),
content: leftPanelBasic(this._appModel, panelOpen),
},
headerMain: this._buildMainHeader(),
contentTop: buildHomeBanners(this._appModel),
contentMain: dom.create(this._buildMainContent.bind(this)),
});
}
private _buildMainHeader() {
return dom.frag(
cssBreadcrumbs({style: 'margin-left: 16px;'},
cssLink(
urlState().setLinkUrl({}),
t('Home'),
),
separator(' / '),
dom('span', getAdminPanelName()),
),
createTopBarHome(this._appModel),
);
}
private _buildMainContent(owner: MultiHolder) {
return cssPageContainer(
dom.cls('clipboard'),
{tabIndex: "-1"},
cssSection(
cssSectionTitle(t('Support Grist')),
this._buildItem(owner, {
id: 'telemetry',
name: t('Telemetry'),
description: t('Help us make Grist better'),
value: maybeSwitchToggle(this._supportGrist.getTelemetryOptInObservable()),
expandedContent: this._supportGrist.buildTelemetrySection(),
}),
this._buildItem(owner, {
id: 'sponsor',
name: t('Sponsor'),
description: t('Support Grist Labs on GitHub'),
value: this._supportGrist.buildSponsorshipSmallButton(),
expandedContent: this._supportGrist.buildSponsorshipSection(),
}),
),
cssSection(
cssSectionTitle(t('Security Settings')),
this._buildItem(owner, {
id: 'sandboxing',
name: t('Sandboxing'),
description: t('Sandbox settings for data engine'),
value: this._buildSandboxingDisplay(owner),
expandedContent: this._buildSandboxingNotice(),
}),
),
cssSection(
cssSectionTitle(t('Version')),
this._buildItem(owner, {
id: 'version',
name: t('Current'),
description: t('Current version of Grist'),
value: cssValueLabel(`Version ${version.version}`),
}),
this._buildUpdates(owner),
),
testId('admin-panel'),
);
}
private _buildSandboxingDisplay(owner: IDisposableOwner) {
return dom.domComputed(
use => {
const req = this._checks.requestCheckById(use, 'sandboxing');
const result = req ? use(req.result) : undefined;
const success = result?.success;
const details = result?.details as SandboxingBootProbeDetails|undefined;
if (!details) {
return cssValueLabel(t('unknown'));
}
const flavor = details.flavor;
const configured = details.configured;
return cssValueLabel(
configured ?
(success ? cssHappy(t('OK') + `: ${flavor}`) :
cssError(t('Error') + `: ${flavor}`)) :
cssError(t('unconfigured')));
}
);
}
private _buildSandboxingNotice() {
return [
t('Grist allows for very powerful formulas, using Python. \
We recommend setting the environment variable GRIST_SANDBOX_FLAVOR to gvisor \
if your hardware supports it (most will), \
to run formulas in each document within a sandbox \
isolated from other documents and isolated from the network.'),
dom(
'div',
{style: 'margin-top: 8px'},
cssLink({href: commonUrls.helpSandboxing, target: '_blank'}, t('Learn more.'))
),
];
}
private _buildItem(owner: IDisposableOwner, options: {
id: string,
name: DomContents,
description: DomContents,
value: DomContents,
expandedContent?: DomContents,
}) {
const itemContent = [
cssItemName(options.name, testId(`admin-panel-item-name-${options.id}`)),
cssItemDescription(options.description),
cssItemValue(options.value,
testId(`admin-panel-item-value-${options.id}`),
dom.on('click', ev => ev.stopPropagation())),
];
if (options.expandedContent) {
const isCollapsed = Observable.create(owner, true);
return cssItem(
cssItemShort(
dom.domComputed(isCollapsed, (c) => cssCollapseIcon(c ? 'Expand' : 'Collapse')),
itemContent,
cssItemShort.cls('-expandable'),
dom.on('click', () => isCollapsed.set(!isCollapsed.get())),
),
cssExpandedContentWrap(
transition(isCollapsed, {
prepare(elem, close) { elem.style.maxHeight = close ? elem.scrollHeight + 'px' : '0'; },
run(elem, close) { elem.style.maxHeight = close ? '0' : elem.scrollHeight + 'px'; },
finish(elem, close) { elem.style.maxHeight = close ? '0' : 'unset'; },
}),
cssExpandedContent(
options.expandedContent,
),
),
testId(`admin-panel-item-${options.id}`),
);
} else {
return cssItem(
cssItemShort(itemContent),
testId(`admin-panel-item-${options.id}`),
);
}
}
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 {
return toggle(value, dom.hide((use) => use(value) === null));
}
const cssPageContainer = styled('div', `
overflow: auto;
padding: 40px;
font-size: ${vars.introFontSize};
color: ${theme.text};
@media ${mediaSmall} {
& {
padding: 0px;
font-size: ${vars.mediumFontSize};
}
}
`);
const cssSection = styled('div', `
padding: 24px;
max-width: 600px;
width: 100%;
margin: 16px auto;
border: 1px solid ${theme.widgetBorder};
border-radius: 4px;
@media ${mediaSmall} {
& {
width: auto;
padding: 12px;
margin: 8px;
}
}
`);
const cssSectionTitle = styled('div', `
height: 32px;
line-height: 32px;
margin-bottom: 16px;
font-size: ${vars.headerControlFontSize};
font-weight: ${vars.headerControlTextWeight};
`);
const cssItem = styled('div', `
margin-top: 8px;
`);
const cssItemShort = styled('div', `
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 8px;
margin: 0 -8px;
border-radius: 4px;
&-expandable {
cursor: pointer;
}
&-expandable:hover {
background-color: ${theme.lightHover};
}
`);
const cssItemName = styled('div', `
width: 112px;
font-weight: bold;
font-size: ${vars.largeFontSize};
&:first-child {
margin-left: 24px;
}
@media ${mediaSmall} {
& {
width: calc(100% - 28px);
}
&:first-child {
margin-left: 0;
}
}
`);
const cssItemDescription = styled('div', `
margin-right: auto;
`);
const cssItemValue = styled('div', `
flex: none;
margin: -16px;
padding: 16px;
cursor: auto;
`);
const cssCollapseIcon = styled(icon, `
width: 24px;
height: 24px;
margin-right: 4px;
margin-left: -4px;
--icon-color: ${theme.lightText};
`);
const cssExpandedContentWrap = styled('div', `
transition: max-height 0.3s ease-in-out;
overflow: hidden;
max-height: 0;
`);
const cssExpandedContent = styled('div', `
margin-left: 24px;
padding: 24px 0;
border-bottom: 1px solid ${theme.widgetBorder};
.${cssItem.className}:last-child & {
padding-bottom: 0;
border-bottom: none;
}
`);
const cssValueLabel = styled('div', `
padding: 4px 8px;
color: ${theme.text};
border: 1px solid ${theme.inputBorder};
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};
`);
export const cssError = styled('div', `
color: ${theme.errorText};
`);
export const cssHappy = styled('div', `
color: ${theme.controlFg};
`);