gristlabs_grist-core/app/client/components/DocumentUsage.ts
2023-01-03 17:22:58 +01:00

434 lines
14 KiB
TypeScript

import {DocPageModel} from 'app/client/models/DocPageModel';
import {urlState} from 'app/client/models/gristUrlState';
import {docListHeader} from 'app/client/ui/DocMenuCss';
import {GristTooltips, TooltipContentFunc} from 'app/client/ui/GristTooltips';
import {withInfoTooltip} from 'app/client/ui/tooltips';
import {mediaXSmall, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {loadingDots, loadingSpinner} from 'app/client/ui2018/loaders';
import {APPROACHING_LIMIT_RATIO, DataLimitStatus} from 'app/common/DocUsage';
import {Features, isFreePlan} from 'app/common/Features';
import {capitalizeFirstWord} from 'app/common/gutil';
import {canUpgradeOrg} from 'app/common/roles';
import {Computed, Disposable, dom, DomContents, DomElementArg, makeTestId, styled} from 'grainjs';
import {makeT} from 'app/client/lib/localization';
const t = makeT('DocumentUsage');
const testId = makeTestId('test-doc-usage-');
// Default used by the progress bar to visually indicate row usage.
const DEFAULT_MAX_ROWS = 20000;
// Default used by the progress bar to visually indicate data size usage.
const DEFAULT_MAX_DATA_SIZE = DEFAULT_MAX_ROWS * 2 * 1024; // 40MB (2KiB per row)
// Default used by the progress bar to visually indicate attachments size usage.
const DEFAULT_MAX_ATTACHMENTS_SIZE = 1 * 1024 * 1024 * 1024; // 1GiB
/**
* Displays statistics about document usage, such as number of rows used.
*/
export class DocumentUsage extends Disposable {
private readonly _currentDoc = this._docPageModel.currentDoc;
private readonly _currentDocUsage = this._docPageModel.currentDocUsage;
private readonly _currentOrg = this._docPageModel.currentOrg;
private readonly _currentProduct = this._docPageModel.currentProduct;
// TODO: Update this whenever the rest of the UI is internationalized.
private readonly _rowCountFormatter = new Intl.NumberFormat('en-US');
private readonly _dataLimitStatus = Computed.create(this, this._currentDocUsage, (_use, usage) => {
return usage?.dataLimitStatus ?? null;
});
private readonly _rowCount = Computed.create(this, this._currentDocUsage, (_use, usage) => {
return usage?.rowCount;
});
private readonly _dataSizeBytes = Computed.create(this, this._currentDocUsage, (_use, usage) => {
return usage?.dataSizeBytes;
});
private readonly _attachmentsSizeBytes = Computed.create(this, this._currentDocUsage, (_use, usage) => {
return usage?.attachmentsSizeBytes;
});
private readonly _rowMetricOptions: Computed<MetricOptions> =
Computed.create(this, this._currentProduct, this._rowCount, (_use, product, rowCount) => {
const maxRows = product?.features.baseMaxRowsPerDocument;
// Invalid row limits are currently treated as if they are undefined.
const maxValue = maxRows && maxRows > 0 ? maxRows : undefined;
return {
name: t("Rows"),
currentValue: typeof rowCount !== 'object' ? undefined : rowCount.total,
maximumValue: maxValue ?? DEFAULT_MAX_ROWS,
unit: 'rows',
shouldHideLimits: maxValue === undefined,
formatValue: (val) => this._rowCountFormatter.format(val),
};
});
private readonly _dataSizeMetricOptions: Computed<MetricOptions> =
Computed.create(this, this._currentProduct, this._dataSizeBytes, (_use, product, dataSize) => {
const maxSize = product?.features.baseMaxDataSizePerDocument;
// Invalid data size limits are currently treated as if they are undefined.
const maxValue = maxSize && maxSize > 0 ? maxSize : undefined;
return {
name: t("Data Size"),
currentValue: typeof dataSize !== 'number' ? undefined : dataSize,
maximumValue: maxValue ?? DEFAULT_MAX_DATA_SIZE,
unit: 'MB',
shouldHideLimits: maxValue === undefined,
tooltipContentFunc: GristTooltips.dataSize,
formatValue: (val) => {
// To display a nice, round number for `maximumValue`, we first convert
// to KiBs (base-2), and then convert to MBs (base-10). Normally, we wouldn't
// mix conversions like this, but to display something that matches our
// marketing limits (e.g. 40MB for Pro plan), we need to bend conversions a bit.
return ((val / 1024) / 1000).toFixed(2);
},
};
});
private readonly _attachmentsSizeMetricOptions: Computed<MetricOptions> =
Computed.create(this, this._currentProduct, this._attachmentsSizeBytes, (_use, product, attachmentsSize) => {
const maxSize = product?.features.baseMaxAttachmentsBytesPerDocument;
// Invalid attachments size limits are currently treated as if they are undefined.
const maxValue = maxSize && maxSize > 0 ? maxSize : undefined;
return {
name: t("Attachments Size"),
currentValue: typeof attachmentsSize !== 'number' ? undefined : attachmentsSize,
maximumValue: maxValue ?? DEFAULT_MAX_ATTACHMENTS_SIZE,
unit: 'GB',
shouldHideLimits: maxValue === undefined,
formatValue: (val) => (val / (1024 * 1024 * 1024)).toFixed(2),
};
});
private readonly _areAllMetricsPending: Computed<boolean> =
Computed.create(
this, this._currentDoc, this._rowCount, this._dataSizeBytes, this._attachmentsSizeBytes,
(_use, doc, rowCount, dataSize, attachmentsSize) => {
const hasNonPendingMetrics = [rowCount, dataSize, attachmentsSize]
.some(metric => metric !== 'pending' && metric !== undefined);
return !doc || !hasNonPendingMetrics;
}
);
private readonly _isAccessDenied: Computed<boolean | null> =
Computed.create(this, this._areAllMetricsPending, this._currentDoc, this._rowCount,
this._dataSizeBytes, this._attachmentsSizeBytes,
(_use, isLoading, doc, rowCount, dataSize, attachmentsSize) => {
if (isLoading) { return null; }
const {access} = doc!.workspace.org;
const isPublicUser = access === 'guests' || access === null;
const hasHiddenMetrics = [rowCount, dataSize, attachmentsSize].some(metric => metric === 'hidden');
return isPublicUser || hasHiddenMetrics;
}
);
constructor(private _docPageModel: DocPageModel) {
super();
}
public buildDom() {
return dom('div',
cssHeader(t("Usage"), testId('heading')),
dom.domComputed(this._areAllMetricsPending, (isLoading) => {
if (isLoading) { return cssSpinner(loadingSpinner(), testId('loading')); }
return [this._buildMessage(), this._buildMetrics()];
}),
testId('container'),
);
}
private _buildMessage() {
return dom.domComputed((use) => {
const isAccessDenied = use(this._isAccessDenied);
if (isAccessDenied === null) { return null; }
if (isAccessDenied) { return buildMessage(t("Usage statistics are only available to users with full access to the document data.")); }
const org = use(this._currentOrg);
const product = use(this._currentProduct);
const status = use(this._dataLimitStatus);
if (!org || !status) { return null; }
return buildMessage([
buildLimitStatusMessage(status, product?.features, {
disableRawDataLink: true
}),
(product && isFreePlan(product.name)
? [' ', buildUpgradeMessage(
canUpgradeOrg(org),
'long',
() => this._docPageModel.appModel.showUpgradeModal()
)]
: null
),
]);
});
}
private _buildMetrics() {
return dom.maybe(use => use(this._isAccessDenied) === false, () =>
cssUsageMetrics(
dom.domComputed(this._rowMetricOptions, (metrics) =>
buildUsageMetric(metrics, testId('rows')),
),
dom.domComputed(this._dataSizeMetricOptions, (metrics) =>
buildUsageMetric(metrics, testId('data-size')),
),
dom.domComputed(this._attachmentsSizeMetricOptions, (metrics) =>
buildUsageMetric(metrics, testId('attachments-size')),
),
testId('metrics'),
),
);
}
}
export function buildLimitStatusMessage(
status: NonNullable<DataLimitStatus>,
features?: Features,
options: {
disableRawDataLink?: boolean;
} = {}
) {
const {disableRawDataLink = false} = options;
switch (status) {
case 'approachingLimit': {
return [
'This document is ',
disableRawDataLink ? 'approaching' : buildRawDataPageLink('approaching'),
' free plan limits.'
];
}
case 'gracePeriod': {
const gracePeriodDays = features?.gracePeriodDays;
if (!gracePeriodDays) {
return [
'Document limits ',
disableRawDataLink ? 'exceeded' : buildRawDataPageLink('exceeded'),
'.'
];
}
return [
'Document limits ',
disableRawDataLink ? 'exceeded' : buildRawDataPageLink('exceeded'),
`. In ${gracePeriodDays} days, this document will be read-only.`
];
}
case 'deleteOnly': {
return [
'This document ',
disableRawDataLink ? 'exceeded' : buildRawDataPageLink('exceeded'),
' free plan limits and is now read-only, but you can delete rows.'
];
}
}
}
export function buildUpgradeMessage(
canUpgrade: boolean,
variant: 'short' | 'long',
onUpgrade: () => void,
) {
if (!canUpgrade) { return t("Contact the site owner to upgrade the plan to raise limits."); }
const upgradeLinkText = t("start your 30-day free trial of the Pro plan.");
// TODO i18next
return [
variant === 'short' ? null : t("For higher limits, "),
buildUpgradeLink(
variant === 'short' ? capitalizeFirstWord(upgradeLinkText) : upgradeLinkText,
() => onUpgrade(),
),
];
}
function buildUpgradeLink(linkText: string, onClick: () => void) {
return cssUnderlinedLink(linkText, dom.on('click', () => onClick()));
}
function buildRawDataPageLink(linkText: string) {
return cssUnderlinedLink(linkText, urlState().setLinkUrl({docPage: 'data'}));
}
interface MetricOptions {
name: string;
// If undefined, loading dots will be shown.
currentValue?: number;
// If undefined or non-positive (i.e. invalid), no limits will be assumed.
maximumValue?: number;
unit?: string;
// If true, limits will always be hidden, even if `maximumValue` is a positive number.
shouldHideLimits?: boolean;
// Shows an icon next to the metric name that displays a tooltip on hover.
tooltipContentFunc?: TooltipContentFunc;
formatValue?(value: number): string;
}
/**
* Builds a component which displays the current and maximum values for
* a particular metric (e.g. row count), and a progress meter showing how
* close `currentValue` is to hitting `maximumValue`.
*/
function buildUsageMetric(options: MetricOptions, ...domArgs: DomElementArg[]) {
const {name, tooltipContentFunc} = options;
return cssUsageMetric(
cssMetricName(
tooltipContentFunc
? withInfoTooltip(
cssOverflowableText(name, testId('name')),
tooltipContentFunc()
)
: cssOverflowableText(name, testId('name')),
),
buildUsageProgressBar(options),
...domArgs,
);
}
function buildUsageProgressBar(options: MetricOptions) {
const {
currentValue,
maximumValue,
shouldHideLimits,
unit,
formatValue = (n) => n.toString()
} = options;
let ratioUsed: number;
let percentUsed: number;
if (currentValue === undefined) {
ratioUsed = 0;
percentUsed = 0;
} else {
ratioUsed = currentValue / (maximumValue || Infinity);
percentUsed = Math.min(100, Math.floor(ratioUsed * 100));
}
return [
cssProgressBarContainer(
cssProgressBarFill(
{style: `width: ${percentUsed}%`},
// Change progress bar to red if close to limit, unless limits are hidden.
shouldHideLimits || ratioUsed <= APPROACHING_LIMIT_RATIO
? null
: cssProgressBarFill.cls('-approaching-limit'),
testId('progress-fill'),
),
),
dom('div',
currentValue === undefined ? ['Loading ', cssLoadingDots()] : formatValue(currentValue)
+ (shouldHideLimits || !maximumValue ? '' : ' of ' + formatValue(maximumValue))
+ (unit ? ` ${unit}` : ''),
testId('value'),
),
];
}
function buildMessage(message: DomContents) {
return cssWarningMessage(
cssIcon('Idea'),
cssLightlyBoldedText(message, testId('message-text')),
testId('message'),
);
}
const cssLightlyBoldedText = styled('div', `
font-weight: 500;
`);
const cssWarningMessage = styled('div', `
color: ${theme.text};
--icon-color: ${theme.text};
display: flex;
gap: 16px;
margin-top: 16px;
`);
const cssIcon = styled(icon, `
flex-shrink: 0;
width: 16px;
height: 16px;
`);
const cssMetricName = styled('div', `
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
`);
const cssOverflowableText = styled('span', `
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`);
const cssHeader = styled(docListHeader, `
margin-bottom: 0px;
`);
const cssUnderlinedLink = styled('span', `
cursor: pointer;
color: unset;
text-decoration: underline;
&:hover, &:focus {
color: unset;
}
`);
const cssUsageMetrics = styled('div', `
display: flex;
flex-wrap: wrap;
margin-top: 24px;
row-gap: 24px;
column-gap: 54px;
`);
const cssUsageMetric = styled('div', `
color: ${theme.text};
display: flex;
flex-direction: column;
width: 180px;
gap: 8px;
@media ${mediaXSmall} {
& {
width: 100%;
}
}
`);
const cssProgressBarContainer = styled('div', `
width: 100%;
height: 4px;
border-radius: 5px;
background: ${theme.progressBarBg};
`);
const cssProgressBarFill = styled(cssProgressBarContainer, `
background: ${theme.progressBarFg};
&-approaching-limit {
background: ${theme.progressBarErrorFg};
}
`);
const cssSpinner = styled('div', `
display: flex;
justify-content: center;
margin-top: 32px;
`);
const cssLoadingDots = styled(loadingDots, `
--dot-size: 8px;
`);