mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Broadcast doc usage updates to clients
Summary: Introduces a new message type, docUsage, that's broadcast to all connected clients whenever document usage is updated in ActiveDoc. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D3451
This commit is contained in:
@@ -63,6 +63,14 @@ import {Events as BackboneEvents} from 'backbone';
|
||||
* @property {Boolean} fromSelf - Flag to indicate whether the action originated from this client.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Event for a change to document usage. Sent to all clients that have this document open.
|
||||
* @event docUsage
|
||||
* @property {Number} docFD - The file descriptor of the open document, specific to each client.
|
||||
* @property {FilteredDocUsageSummary} data.docUsage - Document usage summary.
|
||||
* @property {Product} data.product - Product that was used to compute `data.docUsage`
|
||||
*/
|
||||
|
||||
/**
|
||||
* Event for when a document is forcibly shutdown, and requires the client to re-open it.
|
||||
* @event docShutdown
|
||||
@@ -111,7 +119,7 @@ import {Events as BackboneEvents} from 'backbone';
|
||||
*/
|
||||
|
||||
const ValidEvent = StringUnion('docListAction', 'docUserAction', 'docShutdown', 'docError',
|
||||
'clientConnect', 'clientLogout',
|
||||
'docUsage', 'clientConnect', 'clientLogout',
|
||||
'profileFetch', 'userSettings', 'receiveInvites');
|
||||
type ValidEvent = typeof ValidEvent.type;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {ActiveDocAPI, ApplyUAOptions, ApplyUAResult} from 'app/common/ActiveDocA
|
||||
import {DocAction, UserAction} from 'app/common/DocActions';
|
||||
import {OpenLocalDocResult} from 'app/common/DocListAPI';
|
||||
import {FilteredDocUsageSummary} from 'app/common/DocUsage';
|
||||
import {Product} from 'app/common/Features';
|
||||
import {docUrl} from 'app/common/urlUtils';
|
||||
import {Events as BackboneEvents} from 'backbone';
|
||||
import {Disposable, Emitter} from 'grainjs';
|
||||
@@ -13,7 +14,6 @@ import {Disposable, Emitter} from 'grainjs';
|
||||
// tslint:disable:no-console
|
||||
|
||||
export interface DocUserAction extends CommMessage {
|
||||
docFD: number;
|
||||
fromSelf?: boolean;
|
||||
data: {
|
||||
docActions: DocAction[];
|
||||
@@ -23,6 +23,13 @@ export interface DocUserAction extends CommMessage {
|
||||
};
|
||||
}
|
||||
|
||||
export interface DocUsageMessage extends CommMessage {
|
||||
data: {
|
||||
docUsage: FilteredDocUsageSummary;
|
||||
product?: Product;
|
||||
};
|
||||
}
|
||||
|
||||
const SLOW_NOTIFICATION_TIMEOUT_MS = 1000; // applies to user actions only
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {DocPageModel} from 'app/client/models/DocPageModel';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {docListHeader} from 'app/client/ui/DocMenuCss';
|
||||
import {infoTooltip} from 'app/client/ui/tooltips';
|
||||
import {colors, mediaXSmall} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {cssLink} from 'app/client/ui2018/links';
|
||||
@@ -33,6 +34,7 @@ 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;
|
||||
|
||||
private readonly _dataLimitStatus = Computed.create(this, this._currentDocUsage, (_use, usage) => {
|
||||
return usage?.dataLimitStatus ?? null;
|
||||
@@ -51,8 +53,8 @@ export class DocumentUsage extends Disposable {
|
||||
});
|
||||
|
||||
private readonly _rowMetrics: Computed<MetricOptions | null> =
|
||||
Computed.create(this, this._currentOrg, this._rowCount, (_use, org, rowCount) => {
|
||||
const features = org?.billingAccount?.product.features;
|
||||
Computed.create(this, this._currentProduct, this._rowCount, (_use, product, rowCount) => {
|
||||
const features = product?.features;
|
||||
if (!features || typeof rowCount !== 'number') { return null; }
|
||||
|
||||
const {baseMaxRowsPerDocument: maxRows} = features;
|
||||
@@ -68,8 +70,8 @@ export class DocumentUsage extends Disposable {
|
||||
});
|
||||
|
||||
private readonly _dataSizeMetrics: Computed<MetricOptions | null> =
|
||||
Computed.create(this, this._currentOrg, this._dataSizeBytes, (_use, org, dataSize) => {
|
||||
const features = org?.billingAccount?.product.features;
|
||||
Computed.create(this, this._currentProduct, this._dataSizeBytes, (_use, product, dataSize) => {
|
||||
const features = product?.features;
|
||||
if (!features || typeof dataSize !== 'number') { return null; }
|
||||
|
||||
const {baseMaxDataSizePerDocument: maxSize} = features;
|
||||
@@ -81,6 +83,10 @@ export class DocumentUsage extends Disposable {
|
||||
maximumValue: maxValue ?? DEFAULT_MAX_DATA_SIZE,
|
||||
unit: 'MB',
|
||||
shouldHideLimits: maxValue === undefined,
|
||||
tooltipContent: () => cssTooltipBody(
|
||||
dom('div', 'The total size of all data in this document, excluding attachments.'),
|
||||
dom('div', 'Updates every 5 minutes.'),
|
||||
),
|
||||
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
|
||||
@@ -92,8 +98,8 @@ export class DocumentUsage extends Disposable {
|
||||
});
|
||||
|
||||
private readonly _attachmentsSizeMetrics: Computed<MetricOptions | null> =
|
||||
Computed.create(this, this._currentOrg, this._attachmentsSizeBytes, (_use, org, attachmentsSize) => {
|
||||
const features = org?.billingAccount?.product.features;
|
||||
Computed.create(this, this._currentProduct, this._attachmentsSizeBytes, (_use, product, attachmentsSize) => {
|
||||
const features = product?.features;
|
||||
if (!features || typeof attachmentsSize !== 'number') { return null; }
|
||||
|
||||
const {baseMaxAttachmentsBytesPerDocument: maxSize} = features;
|
||||
@@ -154,10 +160,10 @@ export class DocumentUsage extends Disposable {
|
||||
if (isAccessDenied) { return buildMessage(ACCESS_DENIED_MESSAGE); }
|
||||
|
||||
const org = use(this._currentOrg);
|
||||
const product = use(this._currentProduct);
|
||||
const status = use(this._dataLimitStatus);
|
||||
if (!org || !status) { return null; }
|
||||
|
||||
const product = org.billingAccount?.product;
|
||||
return buildMessage([
|
||||
buildLimitStatusMessage(status, product?.features, {
|
||||
disableRawDataLink: true
|
||||
@@ -259,6 +265,8 @@ interface MetricOptions {
|
||||
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.
|
||||
tooltipContent?(): DomContents;
|
||||
formatValue?(value: number): string;
|
||||
}
|
||||
|
||||
@@ -274,12 +282,16 @@ function buildUsageMetric(options: MetricOptions, ...domArgs: DomElementArg[]) {
|
||||
maximumValue,
|
||||
unit,
|
||||
shouldHideLimits,
|
||||
tooltipContent,
|
||||
formatValue = (val) => val.toString(),
|
||||
} = options;
|
||||
const ratioUsed = currentValue / (maximumValue || Infinity);
|
||||
const percentUsed = Math.min(100, Math.floor(ratioUsed * 100));
|
||||
return cssUsageMetric(
|
||||
cssMetricName(name, testId('name')),
|
||||
cssMetricName(
|
||||
cssOverflowableText(name, testId('name')),
|
||||
tooltipContent ? infoTooltip(tooltipContent()) : null,
|
||||
),
|
||||
cssProgressBarContainer(
|
||||
cssProgressBarFill(
|
||||
{style: `width: ${percentUsed}%`},
|
||||
@@ -328,9 +340,18 @@ const cssIcon = styled(icon, `
|
||||
`);
|
||||
|
||||
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;
|
||||
`);
|
||||
@@ -385,3 +406,9 @@ const cssSpinner = styled('div', `
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
`);
|
||||
|
||||
const cssTooltipBody = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`);
|
||||
|
||||
@@ -11,7 +11,7 @@ import {CodeEditorPanel} from 'app/client/components/CodeEditorPanel';
|
||||
import * as commands from 'app/client/components/commands';
|
||||
import {CursorPos} from 'app/client/components/Cursor';
|
||||
import {CursorMonitor, ViewCursorPos} from "app/client/components/CursorMonitor";
|
||||
import {DocComm, DocUserAction} from 'app/client/components/DocComm';
|
||||
import {DocComm, DocUsageMessage, DocUserAction} from 'app/client/components/DocComm';
|
||||
import * as DocConfigTab from 'app/client/components/DocConfigTab';
|
||||
import {Drafts} from "app/client/components/Drafts";
|
||||
import {EditorMonitor} from "app/client/components/EditorMonitor";
|
||||
@@ -61,7 +61,8 @@ import {LocalPlugin} from "app/common/plugin";
|
||||
import {StringUnion} from 'app/common/StringUnion';
|
||||
import {TableData} from 'app/common/TableData';
|
||||
import {DocStateComparison} from 'app/common/UserAPI';
|
||||
import {Computed, dom, Emitter, Holder, IDisposable, IDomComponent, Observable, styled, subscribe, toKo} from 'grainjs';
|
||||
import {bundleChanges, Computed, dom, Emitter, Holder, IDisposable, IDomComponent, Observable,
|
||||
styled, subscribe, toKo} from 'grainjs';
|
||||
import * as ko from 'knockout';
|
||||
import cloneDeepWith = require('lodash/cloneDeepWith');
|
||||
import isEqual = require('lodash/isEqual');
|
||||
@@ -298,6 +299,8 @@ export class GristDoc extends DisposableWithEvents {
|
||||
|
||||
this.listenTo(app.comm, 'docUserAction', this.onDocUserAction);
|
||||
|
||||
this.listenTo(app.comm, 'docUsage', this.onDocUsageMessage);
|
||||
|
||||
this.autoDispose(DocConfigTab.create({gristDoc: this}));
|
||||
|
||||
this.rightPanelTool = Computed.create(this, (use) => this._getToolContent(use(this._rightPanelTool)));
|
||||
@@ -482,6 +485,19 @@ export class GristDoc extends DisposableWithEvents {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process usage and product received from the server by updating their respective
|
||||
* observables.
|
||||
*/
|
||||
public onDocUsageMessage(message: DocUsageMessage) {
|
||||
if (!this.docComm.isActionFromThisDoc(message)) { return; }
|
||||
|
||||
bundleChanges(() => {
|
||||
this.docPageModel.updateCurrentDocUsage(message.data.docUsage);
|
||||
this.docPageModel.currentProduct.set(message.data.product ?? null);
|
||||
});
|
||||
}
|
||||
|
||||
public getTableModel(tableId: string): DataTableModel {
|
||||
return this.docModel.dataTables[tableId];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Banner, buildBannerMessage} from 'app/client/components/Banner';
|
||||
import {buildUpgradeMessage} from 'app/client/components/DocumentUsage';
|
||||
import {sessionStorageBoolObs} from 'app/client/lib/localStorageObs';
|
||||
import {HomeModel} from 'app/client/models/HomeModel';
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {isFreeProduct} from 'app/common/Features';
|
||||
import {isOwner} from 'app/common/roles';
|
||||
import {Disposable, dom, makeTestId, Observable} from 'grainjs';
|
||||
@@ -9,15 +9,15 @@ import {Disposable, dom, makeTestId, Observable} from 'grainjs';
|
||||
const testId = makeTestId('test-site-usage-banner-');
|
||||
|
||||
export class SiteUsageBanner extends Disposable {
|
||||
private readonly _currentOrg = this._homeModel.app.currentOrg;
|
||||
private readonly _currentOrgUsage = this._homeModel.currentOrgUsage;
|
||||
private readonly _currentOrg = this._app.currentOrg;
|
||||
private readonly _currentOrgUsage = this._app.currentOrgUsage;
|
||||
private readonly _product = this._currentOrg?.billingAccount?.product;
|
||||
private readonly _currentUser = this._homeModel.app.currentValidUser;
|
||||
private readonly _currentUser = this._app.currentValidUser;
|
||||
|
||||
// Session storage observable. Set to false to dismiss the banner for the session.
|
||||
private _showApproachingLimitBannerPref?: Observable<boolean>;
|
||||
|
||||
constructor(private _homeModel: HomeModel) {
|
||||
constructor(private _app: AppModel) {
|
||||
super();
|
||||
|
||||
if (this._currentUser && isOwner(this._currentOrg)) {
|
||||
|
||||
Reference in New Issue
Block a user