reconcile boot and admin pages further

This adds some remaining parts of the boot page to
the admin panel, and then equalizes their content.

The boot page then becomes just a fallback way to
access the admin panel if there is a problem serving
assets or if auth is messed up.

One more step down the road and we can equate them
entirely and just speak of a single admin panel with
a fallback access method, but that isn't done here
yet, some URL work is needed.
Paul Fitzpatrick 3 weeks ago
parent 6299db6872
commit 8e2a0aebba
No known key found for this signature in database
GPG Key ID: 07F16BF3214888F6

@ -1,8 +1,8 @@
import { AppModel } from 'app/client/models/AppModel';
import { AdminChecks, ProbeDetails } from 'app/client/models/AdminChecks';
import { AdminChecks } from 'app/client/models/AdminChecks';
import { AdminPanel } from 'app/client/ui/AdminPanel';
import { createAppPage } from 'app/client/ui/createAppPage';
import { pagePanels } from 'app/client/ui/PagePanels';
import { BootProbeInfo, BootProbeResult } from 'app/common/BootProbe';
import { getGristConfig } from 'app/common/urlUtils';
import { Disposable, dom, Observable, styled, UseCBOwner } from 'grainjs';
@ -27,12 +27,18 @@ const cssResult = styled('div', `
* to have to worry about its failure modes yet, but it should be
* fine as long as assets served locally are used.
*
* This page now shows the same content as the Admin Panel, and
* serves as a way to access that panel in difficult situations,
* for example if auth is failing or asset serving is messed up
* because of some APP_HOME_URL problem etc. TODO: reconcile
* still further.
*
*/
export class Boot extends Disposable {
private _checks: AdminChecks;
constructor(_appModel: AppModel) {
constructor(private _appModel: AppModel) {
super();
// Setting title in constructor seems to be how we are doing this,
// based on other similar pages.
@ -79,11 +85,7 @@ export class Boot extends Disposable {
return cssBody(cssResult(this.buildError()));
}
return cssBody([
...use(this._checks.probes).map(probe => {
const req = this._checks.requestCheck(probe);
return cssResult(
this.buildResult(req.probe, use(req.result), req.details));
}),
dom.create(AdminPanel, this._appModel, true),
]);
}
@ -109,36 +111,6 @@ export class Boot extends Disposable {
' or it is not in the URL.'),
);
}
/**
* An ugly rendering of information returned by the probe.
*/
public buildResult(info: BootProbeInfo, result: BootProbeResult,
details: ProbeDetails|undefined) {
const out: (HTMLElement|string|null)[] = [];
out.push(dom('h2', info.name));
if (details) {
out.push(dom('p', '> ', details.info));
}
if (result.verdict) {
out.push(dom('pre', result.verdict));
}
if (result.success !== undefined) {
out.push(result.success ? '✅' : '❌');
}
if (result.done === true) {
out.push(dom('p', 'no fault detected'));
}
if (result.details) {
for (const [key, val] of Object.entries(result.details)) {
out.push(dom(
'div',
cssLabel(key),
dom('input', dom.prop('value', JSON.stringify(val)))));
}
}
return out;
}
}
/**

@ -42,8 +42,12 @@ export class AdminChecks {
url.pathname += '/probe';
const resp = await fetch(url.href);
const _probes = await resp.json();
this.probes.set(_probes.probes);
if (!this._parent.isDisposed()) {
this.probes.set(_probes.probes);
}
return _probes.probes;
}
return [];
}
/**
@ -99,6 +103,7 @@ export class AdminCheckRunner {
url.pathname = url.pathname + '/probe/' + id;
fetch(url.href).then(async resp => {
const _probes: BootProbeResult = await resp.json();
if (parent.isDisposed()) { return; }
const ob = results.get(id);
if (ob) {
ob.set(_probes);
@ -144,6 +149,17 @@ is set.
`,
},
'sandboxing': {
info: `
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.
`
},
'system-user': {
info: `
It is good practice not to run Grist as the root user.

@ -188,6 +188,28 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
if (this.options.useApi !== false) {
this.fetchUsersAndOrgs().catch(reportError);
} else {
bundleChanges(() => {
this.users.set([{
name: 'Boot',
id: 0,
email: '',
}]);
this.orgs.set([{
id: 0,
owner: {
name: 'Boot',
id: 0,
email: '',
},
host: '',
access: 'owners',
domain: 'boot',
name: 'Boot',
createdAt: String(Date.now()),
updatedAt: String(Date.now()),
}]);
});
}
}

@ -2,8 +2,8 @@ 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 {AdminChecks, ProbeDetails} from 'app/client/models/AdminChecks';
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';
@ -17,7 +17,7 @@ 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 {BootProbeInfo, BootProbeResult, 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';
@ -39,7 +39,7 @@ export class AdminPanel extends Disposable {
private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl());
private _checks: AdminChecks;
constructor(private _appModel: AppModel) {
constructor(private _appModel: AppModel, private _fullScreen: boolean = false) {
super();
document.title = getAdminPanelName() + getPageTitleSuffix(getGristConfig());
this._checks = new AdminChecks(this);
@ -49,6 +49,9 @@ export class AdminPanel extends Disposable {
this._checks.fetchAvailableChecks().catch(err => {
reportError(err);
});
if (this._fullScreen) {
return dom.create(this._buildMainContent.bind(this));
}
const panelOpen = Observable.create(this, false);
return pagePanels({
leftPanel: {
@ -119,6 +122,23 @@ export class AdminPanel extends Disposable {
}),
this._buildUpdates(owner),
),
cssSection(
cssSectionTitle(t('Self Checks')),
this._buildProbeItems(owner, {
showRedundant: false,
showNovel: true,
}),
this._buildItem(owner, {
id: 'probe-other',
name: 'more...',
description: '',
value: '',
expandedContent: this._buildProbeItems(owner, {
showRedundant: true,
showNovel: false,
}),
}),
),
testId('admin-panel'),
);
}
@ -145,6 +165,7 @@ export class AdminPanel extends Disposable {
}
private _buildSandboxingNotice() {
// TODO: reconcile with AdminChecks text for sandboxing.
return [
t('Grist allows for very powerful formulas, using Python. \
We recommend setting the environment variable GRIST_SANDBOX_FLAVOR to gvisor \
@ -420,12 +441,100 @@ isolated from other documents and isolated from the network.'),
)
});
}
/**
* Show the results of various checks. Of the checks, some are considered
* "redundant" (already covered elsewhere in the Admin Panel) and the
* remainder are "novel".
*/
private _buildProbeItems(owner: MultiHolder, options: {
showRedundant: boolean,
showNovel: boolean,
}) {
return dom.domComputed(
use => [
...use(this._checks.probes).map(probe => {
const isRedundant = probe.id === 'sandboxing';
const show = isRedundant ? options.showRedundant : options.showNovel;
if (!show) { return null; }
const req = this._checks.requestCheck(probe);
return this._buildProbeItem(owner, req.probe, use(req.result), req.details);
}),
]
);
}
/**
* Show the result of an individual check.
*/
private _buildProbeItem(owner: MultiHolder,
info: BootProbeInfo,
result: BootProbeResult,
details: ProbeDetails|undefined) {
const out: (HTMLElement|string|null)[] = [];
if (result.verdict) {
out.push(dom('pre', result.verdict));
}
if (result.success !== undefined) {
out.push(dom('p',
result.success ? 'Check succeeded.' : 'Check failed.'));
}
if (result.details) {
for (const [key, val] of Object.entries(result.details)) {
out.push(dom(
'div',
cssLabel(key),
dom('input', dom.prop('value', JSON.stringify(val)))));
}
}
const status = (result.success !== undefined) ?
(result.success ? '✅' : '❗') : '―';
return this._buildItem(owner, {
id: `probe-${info.id}`,
name: info.id,
description: info.name,
value: cssStatus(status),
expandedContent: [
result.verdict ? dom('pre', result.verdict) : null,
(result.success === undefined) ? null :
dom('p',
result.success ? 'Check succeeded.' : 'Check failed.'),
(result.done !== true) ? null :
dom('p', 'No fault detected.'),
(details?.info === undefined) ? null :
cssNote(details.info),
(result.details === undefined) ? null :
Object.entries(result.details).map(([key, val]) => {
return dom(
'div',
cssLabel(key),
dom('input', dom.prop('value', JSON.stringify(val))))
}),
],
});
}
}
function maybeSwitchToggle(value: Observable<boolean|null>): DomContents {
return toggle(value, dom.hide((use) => use(value) === null));
}
const cssNote = styled('div', `
border-left: 2px solid ${theme.lightText};
padding-left: 5px;
padding-bottom: 5px;
padding-top: 5px;
margin-bottom: 5px;
`);
const cssStatus = styled('div', `
display: inline-block;
text-align: center;
width: 40px;
padding: 5px;
`);
const cssPageContainer = styled('div', `
overflow: auto;
padding: 40px;
@ -598,3 +707,10 @@ export const cssError = styled('div', `
export const cssHappy = styled('div', `
color: ${theme.controlFg};
`);
export const cssLabel = styled('div', `
display: inline-block;
min-width: 100px;
text-align: right;
padding-right: 5px;
`);

@ -75,7 +75,7 @@ export interface Probe {
const _homeUrlReachableProbe: Probe = {
id: 'reachable',
name: 'Grist is reachable',
name: 'Is home page available at expected URL',
apply: async (server, req) => {
const url = server.getHomeUrl(req);
try {
@ -100,7 +100,7 @@ const _homeUrlReachableProbe: Probe = {
const _statusCheckProbe: Probe = {
id: 'health-check',
name: 'Built-in Health check',
name: 'Is an internal health check passing',
apply: async (server, req) => {
const baseUrl = server.getHomeUrl(req);
const url = new URL(baseUrl);
@ -129,7 +129,7 @@ const _statusCheckProbe: Probe = {
const _userProbe: Probe = {
id: 'system-user',
name: 'System user is sane',
name: 'Is the system user following best practice',
apply: async () => {
if (process.getuid && process.getuid() === 0) {
return {
@ -147,7 +147,7 @@ const _userProbe: Probe = {
const _bootProbe: Probe = {
id: 'boot-page',
name: 'Boot page exposure',
name: 'Is the boot page adequately protected',
apply: async (server) => {
if (!server.hasBoot) {
return { success: true };
@ -169,7 +169,7 @@ const _bootProbe: Probe = {
*/
const _hostHeaderProbe: Probe = {
id: 'host-header',
name: 'Host header is sane',
name: 'Does the host header look correct',
apply: async (server, req) => {
const host = req.header('host');
const url = new URL(server.getHomeUrl(req));
@ -193,7 +193,7 @@ const _hostHeaderProbe: Probe = {
const _sandboxingProbe: Probe = {
id: 'sandboxing',
name: 'Sandboxing is working',
name: 'Is document sandboxing effective',
apply: async (server, req) => {
const details = server.getSandboxInfo();
return {

@ -550,7 +550,7 @@ export class FlexServer implements GristServer {
this._probes = new BootProbes(this.app, this, base);
// Respond to /boot, /boot/, /boot/KEY, /boot/KEY/ to give
// a helpful message even if user gets KEY wrong or omits it.
this.app.get('/boot(/(:bootKey/?)?)?$', async (req, res) => {
this.app.get('/boot(/(:bootKey/?)?)?$', expressWrap(async (req, res) => {
const goodKey = bootKey && req.params.bootKey === bootKey;
return this._sendAppPage(req, res, {
path: 'boot.html', status: 200, config: goodKey ? {
@ -558,7 +558,7 @@ export class FlexServer implements GristServer {
errMessage: 'not-the-key',
}, tag: 'boot',
});
});
}));
this._probes.addEndpoints();
}

Loading…
Cancel
Save