2024-03-23 17:11:06 +00:00
|
|
|
import {buildHomeBanners} from 'app/client/components/Banners';
|
|
|
|
import {makeT} from 'app/client/lib/localization';
|
2024-04-29 14:54:03 +00:00
|
|
|
import {localStorageJsonObs} from 'app/client/lib/localStorageObs';
|
|
|
|
import {getTimeFromNow} from 'app/client/lib/timeUtils';
|
2024-04-30 00:31:10 +00:00
|
|
|
import {AppModel, getHomeUrl, reportError} from 'app/client/models/AppModel';
|
|
|
|
import {AdminChecks} from 'app/client/models/AdminChecks';
|
2024-03-23 17:11:06 +00:00
|
|
|
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 {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs';
|
2024-04-29 14:54:03 +00:00
|
|
|
import {basicButton} from 'app/client/ui2018/buttons';
|
|
|
|
import {toggle} from 'app/client/ui2018/checkbox';
|
2024-03-23 17:11:06 +00:00
|
|
|
import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
|
2024-04-29 14:54:03 +00:00
|
|
|
import {cssLink, makeLinks} from 'app/client/ui2018/links';
|
2024-04-30 00:31:10 +00:00
|
|
|
import {SandboxingBootProbeDetails} from 'app/common/BootProbe';
|
|
|
|
import {commonUrls, getPageTitleSuffix} from 'app/common/gristUrls';
|
2024-04-29 14:54:03 +00:00
|
|
|
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';
|
2024-05-10 12:54:37 +00:00
|
|
|
import {Computed, Disposable, dom, IDisposable,
|
2024-04-29 14:54:03 +00:00
|
|
|
IDisposableOwner, MultiHolder, Observable, styled} from 'grainjs';
|
2024-05-10 12:54:37 +00:00
|
|
|
import {AdminSection, AdminSectionItem, HidableToggle} from 'app/client/ui/AdminPanelCss';
|
2024-04-29 14:54:03 +00:00
|
|
|
|
2024-03-23 17:11:06 +00:00
|
|
|
|
|
|
|
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);
|
2024-04-29 14:54:03 +00:00
|
|
|
private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl());
|
2024-04-30 00:31:10 +00:00
|
|
|
private _checks: AdminChecks;
|
2024-03-23 17:11:06 +00:00
|
|
|
|
|
|
|
constructor(private _appModel: AppModel) {
|
|
|
|
super();
|
|
|
|
document.title = getAdminPanelName() + getPageTitleSuffix(getGristConfig());
|
2024-04-30 00:31:10 +00:00
|
|
|
this._checks = new AdminChecks(this);
|
2024-03-23 17:11:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public buildDom() {
|
2024-04-30 00:31:10 +00:00
|
|
|
this._checks.fetchAvailableChecks().catch(err => {
|
|
|
|
reportError(err);
|
|
|
|
});
|
2024-03-23 17:11:06 +00:00
|
|
|
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),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2024-04-29 14:54:03 +00:00
|
|
|
private _buildMainContent(owner: MultiHolder) {
|
2024-03-23 17:11:06 +00:00
|
|
|
return cssPageContainer(
|
|
|
|
dom.cls('clipboard'),
|
|
|
|
{tabIndex: "-1"},
|
2024-05-10 12:54:37 +00:00
|
|
|
dom.create(AdminSection, t('Support Grist'), [
|
|
|
|
dom.create(AdminSectionItem, {
|
2024-03-23 17:11:06 +00:00
|
|
|
id: 'telemetry',
|
|
|
|
name: t('Telemetry'),
|
|
|
|
description: t('Help us make Grist better'),
|
2024-05-10 12:54:37 +00:00
|
|
|
value: dom.create(HidableToggle, this._supportGrist.getTelemetryOptInObservable()),
|
2024-03-23 17:11:06 +00:00
|
|
|
expandedContent: this._supportGrist.buildTelemetrySection(),
|
|
|
|
}),
|
2024-05-10 12:54:37 +00:00
|
|
|
dom.create(AdminSectionItem, {
|
2024-03-23 17:11:06 +00:00
|
|
|
id: 'sponsor',
|
|
|
|
name: t('Sponsor'),
|
|
|
|
description: t('Support Grist Labs on GitHub'),
|
|
|
|
value: this._supportGrist.buildSponsorshipSmallButton(),
|
|
|
|
expandedContent: this._supportGrist.buildSponsorshipSection(),
|
|
|
|
}),
|
2024-05-10 12:54:37 +00:00
|
|
|
]),
|
|
|
|
dom.create(AdminSection, t('Security Settings'), [
|
|
|
|
dom.create(AdminSectionItem, {
|
2024-04-30 00:31:10 +00:00
|
|
|
id: 'sandboxing',
|
|
|
|
name: t('Sandboxing'),
|
|
|
|
description: t('Sandbox settings for data engine'),
|
|
|
|
value: this._buildSandboxingDisplay(owner),
|
|
|
|
expandedContent: this._buildSandboxingNotice(),
|
|
|
|
}),
|
2024-05-10 12:54:37 +00:00
|
|
|
]),
|
|
|
|
|
|
|
|
dom.create(AdminSection, t('Version'), [
|
|
|
|
dom.create(AdminSectionItem, {
|
2024-03-23 17:11:06 +00:00
|
|
|
id: 'version',
|
|
|
|
name: t('Current'),
|
|
|
|
description: t('Current version of Grist'),
|
|
|
|
value: cssValueLabel(`Version ${version.version}`),
|
|
|
|
}),
|
2024-04-29 14:54:03 +00:00
|
|
|
this._buildUpdates(owner),
|
2024-05-10 12:54:37 +00:00
|
|
|
]),
|
2024-03-23 17:11:06 +00:00
|
|
|
testId('admin-panel'),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2024-04-30 00:31:10 +00:00
|
|
|
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 ?
|
2024-05-10 12:54:37 +00:00
|
|
|
(success ? cssHappyText(t('OK') + `: ${flavor}`) :
|
|
|
|
cssErrorText(t('Error') + `: ${flavor}`)) :
|
|
|
|
cssErrorText(t('unconfigured')));
|
2024-04-30 00:31:10 +00:00
|
|
|
}
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
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.'))
|
|
|
|
),
|
|
|
|
];
|
|
|
|
}
|
|
|
|
|
2024-04-29 14:54:03 +00:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2024-05-10 12:54:37 +00:00
|
|
|
return dom.create(AdminSectionItem, {
|
2024-04-29 14:54:03 +00:00
|
|
|
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')),
|
|
|
|
),
|
|
|
|
)
|
|
|
|
});
|
|
|
|
}
|
2024-03-23 17:11:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
const cssPageContainer = styled('div', `
|
|
|
|
overflow: auto;
|
|
|
|
padding: 40px;
|
|
|
|
font-size: ${vars.introFontSize};
|
|
|
|
color: ${theme.text};
|
|
|
|
|
|
|
|
@media ${mediaSmall} {
|
|
|
|
& {
|
|
|
|
padding: 0px;
|
|
|
|
font-size: ${vars.mediumFontSize};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
2024-05-10 12:54:37 +00:00
|
|
|
export const cssValueLabel = styled('div', `
|
2024-03-23 17:11:06 +00:00
|
|
|
padding: 4px 8px;
|
|
|
|
color: ${theme.text};
|
|
|
|
border: 1px solid ${theme.inputBorder};
|
|
|
|
border-radius: ${vars.controlBorderRadius};
|
2024-04-29 14:54:03 +00:00
|
|
|
`);
|
|
|
|
|
|
|
|
// 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};
|
2024-03-23 17:11:06 +00:00
|
|
|
`);
|
2024-04-30 00:31:10 +00:00
|
|
|
|
2024-05-10 12:54:37 +00:00
|
|
|
const cssErrorText = styled('span', `
|
2024-04-30 00:31:10 +00:00
|
|
|
color: ${theme.errorText};
|
|
|
|
`);
|
|
|
|
|
2024-05-10 12:54:37 +00:00
|
|
|
const cssHappyText = styled('span', `
|
2024-04-30 00:31:10 +00:00
|
|
|
color: ${theme.controlFg};
|
|
|
|
`);
|