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 = 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 = 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 = 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 = 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 = 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, 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; `);