fix: Update org status banner on quota reached (#1956)

Fixes https://github.com/webrecorder/browsertrix/issues/1954

### Changes

- Refactors app state to include org data
- Fixes banner not showing if storage or execution minutes is exceeded
after page load
- Disables closing banners
- Refreshes org when tab changes

---------
Co-authored-by: emma <hi@emma.cafe>
This commit is contained in:
sua yoo 2024-07-23 22:55:45 -07:00 committed by GitHub
parent b35669af8d
commit 08147ec77d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 147 additions and 172 deletions

View File

@ -1,17 +1,16 @@
import { localized, msg, str } from "@lit/localize";
import { differenceInDays } from "date-fns/fp";
import { html, type PropertyValues, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { html, type TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import { TailwindElement } from "@/classes/TailwindElement";
import { NavigateController } from "@/controllers/navigate";
import { OrgReadOnlyReason, type OrgData } from "@/types/org";
import { OrgReadOnlyReason } from "@/types/org";
import { formatISODateString } from "@/utils/localization";
import appState, { use } from "@/utils/state";
type Alert = {
test: () => boolean;
persist?: boolean;
content: () => {
title: string | TemplateResult;
detail: string | TemplateResult;
@ -21,64 +20,37 @@ type Alert = {
@localized()
@customElement("btrix-org-status-banner")
export class OrgStatusBanner extends TailwindElement {
@property({ type: Object })
org?: OrgData;
@use()
appState = appState;
@state()
isAlertOpen = false;
private readonly navigate = new NavigateController(this);
private alert?: Alert;
protected willUpdate(_changedProperties: PropertyValues): void {
if (_changedProperties.has("org") && this.org) {
this.alert = this.alerts.find(({ test }) => test());
if (this.alert) {
this.isAlertOpen = true;
}
}
private get org() {
return this.appState.org;
}
render() {
if (!this.org) return;
const alert = this.alerts.find(({ test }) => test());
if (!alert) return;
const content = alert.content();
return html`
<div
class="${this.isAlertOpen
? "bg-slate-100 border-b py-5"
: ""} transition-all"
>
<div id="banner" class="border-b bg-slate-100 py-5">
<div class="mx-auto box-border w-full max-w-screen-desktop px-3">
<sl-alert
variant="danger"
?closable=${!this.alert?.persist}
?open=${this.isAlertOpen}
@sl-after-hide=${() => (this.isAlertOpen = false)}
>
<sl-alert variant="danger" open>
<sl-icon slot="icon" name="exclamation-triangle-fill"></sl-icon>
${this.renderContent()}
<strong class="block font-semibold">${content.title}</strong>
${content.detail}
</sl-alert>
</div>
</div>
`;
}
private renderContent() {
if (!this.alert || !this.org) return;
const content = this.alert.content();
return html`
<strong class="block font-semibold">${content.title}</strong>
${content.detail}
`;
}
/**
* Alerts ordered by priority
*/
@ -105,7 +77,7 @@ export class OrgStatusBanner extends TailwindElement {
{
test: () =>
!readOnly && !readOnlyOnCancel && !!subscription?.futureCancelDate,
persist: true,
content: () => {
const daysDiff = differenceInDays(
new Date(),
@ -147,7 +119,7 @@ export class OrgStatusBanner extends TailwindElement {
{
test: () =>
!readOnly && readOnlyOnCancel && !!subscription?.futureCancelDate,
persist: true,
content: () => {
const daysDiff = differenceInDays(
new Date(),
@ -187,7 +159,7 @@ export class OrgStatusBanner extends TailwindElement {
{
test: () =>
!!readOnly && readOnlyReason === OrgReadOnlyReason.SubscriptionPaused,
persist: true,
content: () => ({
title: msg(str`Your org has been set to read-only mode`),
detail: msg(
@ -200,7 +172,7 @@ export class OrgStatusBanner extends TailwindElement {
test: () =>
!!readOnly &&
readOnlyReason === OrgReadOnlyReason.SubscriptionCancelled,
persist: true,
content: () => ({
title: msg(str`This org has been set to read-only mode`),
detail: msg(
@ -210,7 +182,7 @@ export class OrgStatusBanner extends TailwindElement {
},
{
test: () => !!readOnly,
persist: true,
content: () => ({
title: msg(str`This org has been set to read-only mode`),
detail: msg(`Please contact Browsertrix support to renew your plan.`),

View File

@ -84,7 +84,7 @@ export class Dashboard extends LiteElement {
() =>
html` <sl-icon-button
href=${`${this.orgBasePath}/settings`}
class="text-lg"
class="size-8 text-lg"
name="gear"
label=${msg("Edit org settings")}
@click=${this.navLink}
@ -355,24 +355,26 @@ export class Dashboard extends LiteElement {
}
private renderCrawlingMeter(_metrics: Metrics) {
if (!this.org) return;
let quotaSeconds = 0;
if (this.org!.quotas.maxExecMinutesPerMonth) {
quotaSeconds = this.org!.quotas.maxExecMinutesPerMonth * 60;
if (this.org.quotas.maxExecMinutesPerMonth) {
quotaSeconds = this.org.quotas.maxExecMinutesPerMonth * 60;
}
let quotaSecondsAllTypes = quotaSeconds;
let quotaSecondsExtra = 0;
if (this.org!.extraExecSecondsAvailable) {
quotaSecondsExtra = this.org!.extraExecSecondsAvailable;
quotaSecondsAllTypes += this.org!.extraExecSecondsAvailable;
if (this.org.extraExecSecondsAvailable) {
quotaSecondsExtra = this.org.extraExecSecondsAvailable;
quotaSecondsAllTypes += this.org.extraExecSecondsAvailable;
}
let quotaSecondsGifted = 0;
if (this.org!.giftedExecSecondsAvailable) {
quotaSecondsGifted = this.org!.giftedExecSecondsAvailable;
quotaSecondsAllTypes += this.org!.giftedExecSecondsAvailable;
if (this.org.giftedExecSecondsAvailable) {
quotaSecondsGifted = this.org.giftedExecSecondsAvailable;
quotaSecondsAllTypes += this.org.giftedExecSecondsAvailable;
}
const now = new Date();
@ -381,8 +383,8 @@ export class Dashboard extends LiteElement {
const currentPeriod = `${currentYear}-${currentMonth}` as YearMonth;
let usageSeconds = 0;
if (this.org!.monthlyExecSeconds) {
const actualUsage = this.org!.monthlyExecSeconds[currentPeriod];
if (this.org.monthlyExecSeconds) {
const actualUsage = this.org.monthlyExecSeconds[currentPeriod];
if (actualUsage) {
usageSeconds = actualUsage;
}
@ -393,21 +395,21 @@ export class Dashboard extends LiteElement {
}
let usageSecondsAllTypes = 0;
if (this.org!.crawlExecSeconds) {
const actualUsage = this.org!.crawlExecSeconds[currentPeriod];
if (this.org.crawlExecSeconds) {
const actualUsage = this.org.crawlExecSeconds[currentPeriod];
if (actualUsage) {
usageSecondsAllTypes = actualUsage;
}
}
let usageSecondsExtra = 0;
if (this.org!.extraExecSeconds) {
const actualUsageExtra = this.org!.extraExecSeconds[currentPeriod];
if (this.org.extraExecSeconds) {
const actualUsageExtra = this.org.extraExecSeconds[currentPeriod];
if (actualUsageExtra) {
usageSecondsExtra = actualUsageExtra;
}
}
const maxExecSecsExtra = this.org!.quotas.extraExecMinutes * 60;
const maxExecSecsExtra = this.org.quotas.extraExecMinutes * 60;
// Cap usage at quota for display purposes
if (usageSecondsExtra > maxExecSecsExtra) {
usageSecondsExtra = maxExecSecsExtra;
@ -419,13 +421,13 @@ export class Dashboard extends LiteElement {
}
let usageSecondsGifted = 0;
if (this.org!.giftedExecSeconds) {
const actualUsageGifted = this.org!.giftedExecSeconds[currentPeriod];
if (this.org.giftedExecSeconds) {
const actualUsageGifted = this.org.giftedExecSeconds[currentPeriod];
if (actualUsageGifted) {
usageSecondsGifted = actualUsageGifted;
}
}
const maxExecSecsGifted = this.org!.quotas.giftedExecMinutes * 60;
const maxExecSecsGifted = this.org.quotas.giftedExecMinutes * 60;
// Cap usage at quota for display purposes
if (usageSecondsGifted > maxExecSecsGifted) {
usageSecondsGifted = maxExecSecsGifted;
@ -447,9 +449,9 @@ export class Dashboard extends LiteElement {
const hasExtra =
usageSecondsExtra ||
this.org!.extraExecSecondsAvailable ||
this.org.extraExecSecondsAvailable ||
usageSecondsGifted ||
this.org!.giftedExecSecondsAvailable;
this.org.giftedExecSecondsAvailable;
const renderBar = (
/** Time in Seconds */
@ -502,14 +504,14 @@ export class Dashboard extends LiteElement {
</div>
`,
() =>
hasQuota
hasQuota && this.org
? html`
<span class="inline-flex items-center">
${humanizeExecutionSeconds(
quotaSeconds -
usageSeconds +
this.org!.extraExecSecondsAvailable +
this.org!.giftedExecSecondsAvailable,
this.org.extraExecSecondsAvailable +
this.org.giftedExecSecondsAvailable,
{ style: "short", round: "down" },
)}
<span class="ml-1">${msg("remaining")}</span>
@ -519,12 +521,12 @@ export class Dashboard extends LiteElement {
)}
</div>
${when(
hasQuota,
() => html`
hasQuota && this.org,
(org) => html`
<div class="mb-2">
<btrix-meter
value=${this.org!.giftedExecSecondsAvailable ||
this.org!.extraExecSecondsAvailable ||
value=${org.giftedExecSecondsAvailable ||
org.extraExecSecondsAvailable ||
isReached
? quotaSecondsAllTypes
: usageSeconds}
@ -540,29 +542,25 @@ export class Dashboard extends LiteElement {
hasExtra ? true : false,
),
)}
${when(
usageSecondsGifted || this.org!.giftedExecSecondsAvailable,
() =>
renderBar(
usageSecondsGifted > quotaSecondsGifted
? quotaSecondsGifted
: usageSecondsGifted,
quotaSecondsGifted,
msg("Gifted Execution Time Used"),
"blue",
),
${when(usageSecondsGifted || org.giftedExecSecondsAvailable, () =>
renderBar(
usageSecondsGifted > quotaSecondsGifted
? quotaSecondsGifted
: usageSecondsGifted,
quotaSecondsGifted,
msg("Gifted Execution Time Used"),
"blue",
),
)}
${when(
usageSecondsExtra || this.org!.extraExecSecondsAvailable,
() =>
renderBar(
usageSecondsExtra > quotaSecondsExtra
? quotaSecondsExtra
: usageSecondsExtra,
quotaSecondsExtra,
msg("Extra Execution Time Used"),
"red",
),
${when(usageSecondsExtra || org.extraExecSecondsAvailable, () =>
renderBar(
usageSecondsExtra > quotaSecondsExtra
? quotaSecondsExtra
: usageSecondsExtra,
quotaSecondsExtra,
msg("Extra Execution Time Used"),
"red",
),
)}
<div slot="available" class="flex-1">
<sl-tooltip class="text-center">
@ -664,13 +662,15 @@ export class Dashboard extends LiteElement {
`;
private readonly hasMonthlyTime = () =>
Object.keys(this.org!.monthlyExecSeconds!).length;
this.org?.monthlyExecSeconds &&
Object.keys(this.org.monthlyExecSeconds).length;
private readonly hasExtraTime = () =>
Object.keys(this.org!.extraExecSeconds!).length;
this.org?.extraExecSeconds && Object.keys(this.org.extraExecSeconds).length;
private readonly hasGiftedTime = () =>
Object.keys(this.org!.giftedExecSeconds!).length;
this.org?.giftedExecSeconds &&
Object.keys(this.org.giftedExecSeconds).length;
private renderUsageHistory() {
if (!this.org) return;
@ -750,34 +750,36 @@ export class Dashboard extends LiteElement {
// Sort latest
.reverse()
.map(([mY, crawlTime]) => {
let monthlySecondsUsed = this.org!.monthlyExecSeconds?.[mY] || 0;
if (!this.org) return [];
let monthlySecondsUsed = this.org.monthlyExecSeconds?.[mY] || 0;
let maxMonthlySeconds = 0;
if (this.org!.quotas.maxExecMinutesPerMonth) {
maxMonthlySeconds = this.org!.quotas.maxExecMinutesPerMonth * 60;
if (this.org.quotas.maxExecMinutesPerMonth) {
maxMonthlySeconds = this.org.quotas.maxExecMinutesPerMonth * 60;
}
if (monthlySecondsUsed > maxMonthlySeconds) {
monthlySecondsUsed = maxMonthlySeconds;
}
let extraSecondsUsed = this.org!.extraExecSeconds?.[mY] || 0;
let extraSecondsUsed = this.org.extraExecSeconds?.[mY] || 0;
let maxExtraSeconds = 0;
if (this.org!.quotas.extraExecMinutes) {
maxExtraSeconds = this.org!.quotas.extraExecMinutes * 60;
if (this.org.quotas.extraExecMinutes) {
maxExtraSeconds = this.org.quotas.extraExecMinutes * 60;
}
if (extraSecondsUsed > maxExtraSeconds) {
extraSecondsUsed = maxExtraSeconds;
}
let giftedSecondsUsed = this.org!.giftedExecSeconds?.[mY] || 0;
let giftedSecondsUsed = this.org.giftedExecSeconds?.[mY] || 0;
let maxGiftedSeconds = 0;
if (this.org!.quotas.giftedExecMinutes) {
maxGiftedSeconds = this.org!.quotas.giftedExecMinutes * 60;
if (this.org.quotas.giftedExecMinutes) {
maxGiftedSeconds = this.org.quotas.giftedExecMinutes * 60;
}
if (giftedSecondsUsed > maxGiftedSeconds) {
giftedSecondsUsed = maxGiftedSeconds;
}
let totalSecondsUsed = this.org!.crawlExecSeconds?.[mY] || 0;
let totalSecondsUsed = this.org.crawlExecSeconds?.[mY] || 0;
const totalMaxQuota =
maxMonthlySeconds + maxExtraSeconds + maxGiftedSeconds;
if (totalSecondsUsed > totalMaxQuota) {

View File

@ -24,6 +24,7 @@ import type { AuthState } from "@/utils/AuthService";
import { DEFAULT_MAX_SCALE } from "@/utils/crawler";
import LiteElement, { html } from "@/utils/LiteElement";
import { isAdmin, isCrawler, type OrgData } from "@/utils/orgs";
import { AppStateService } from "@/utils/state";
import "./workflow-detail";
import "./workflows-list";
@ -113,30 +114,12 @@ export class Org extends LiteElement {
@property({ type: Number })
maxScale: number = DEFAULT_MAX_SCALE;
@state()
private orgStorageQuotaReached = false;
@state()
private showReadOnlyAlert = false;
@state()
private showStorageQuotaAlert = false;
@state()
private orgExecutionMinutesQuotaReached = false;
@state()
private showExecutionMinutesQuotaAlert = false;
@state()
private openDialogName?: ResourceName;
@state()
private isCreateDialogVisible = false;
@state()
private org?: OrgData | null;
get userOrg() {
if (!this.userInfo) return null;
return this.userInfo.orgs.find(({ slug }) => slug === this.slug)!;
@ -146,6 +129,10 @@ export class Org extends LiteElement {
return this.userOrg?.id || "";
}
get org() {
return this.appState.org;
}
get isAdmin() {
const userOrg = this.userOrg;
if (userOrg) return isAdmin(userOrg.role);
@ -168,7 +155,6 @@ export class Org extends LiteElement {
"btrix-storage-quota-update",
this.onStorageQuotaUpdate,
);
this.addEventListener("", () => {});
}
disconnectedCallback() {
@ -202,6 +188,9 @@ export class Org extends LiteElement {
return;
}
} else if (changedProperties.has("orgTab") && this.orgId) {
// Get most up to date org data
void this.updateOrg();
}
if (changedProperties.has("openDialogName")) {
// Sync URL to create dialog
@ -231,15 +220,11 @@ export class Org extends LiteElement {
if (!this.userInfo || !this.orgId) return;
try {
this.org = await this.getOrg(this.orgId);
const org = await this.getOrg(this.orgId);
this.showReadOnlyAlert = Boolean(
this.org?.readOnly || this.org?.subscription?.futureCancelDate,
);
this.checkStorageQuota();
this.checkExecutionMinutesQuota();
} catch {
AppStateService.updateOrg(org);
} catch (e) {
console.debug(e);
this.notify({
message: msg("Sorry, couldn't retrieve organization at this time."),
variant: "danger",
@ -319,7 +304,7 @@ export class Org extends LiteElement {
return html`
<div class="flex min-h-full flex-col">
<btrix-org-status-banner .org=${this.org}></btrix-org-status-banner>
<btrix-org-status-banner></btrix-org-status-banner>
${this.renderOrgNavBar()}
<main
class="${noMaxWidth
@ -523,7 +508,7 @@ export class Org extends LiteElement {
.authState=${this.authState!}
userId=${this.userInfo!.id}
orgId=${this.orgId}
?orgStorageQuotaReached=${this.orgStorageQuotaReached}
?orgStorageQuotaReached=${this.org?.storageQuotaReached}
?isCrawler=${this.isCrawler}
itemType=${ifDefined(params.itemType || undefined)}
@select-new-dialog=${this.onSelectNewDialog}
@ -543,9 +528,8 @@ export class Org extends LiteElement {
class="col-span-5 mt-6"
.authState=${this.authState!}
orgId=${this.orgId}
?orgStorageQuotaReached=${this.orgStorageQuotaReached}
?orgExecutionMinutesQuotaReached=${this
.orgExecutionMinutesQuotaReached}
?orgStorageQuotaReached=${this.org?.storageQuotaReached}
?orgExecutionMinutesQuotaReached=${this.org?.execMinutesQuotaReached}
workflowId=${workflowId}
openDialogName=${this.viewStateData?.dialog}
?isEditing=${isEditing}
@ -566,8 +550,8 @@ export class Org extends LiteElement {
.initialWorkflow=${workflow}
.initialSeeds=${seeds}
jobType=${ifDefined(params.jobType)}
?orgStorageQuotaReached=${this.orgStorageQuotaReached}
?orgExecutionMinutesQuotaReached=${this.orgExecutionMinutesQuotaReached}
?orgStorageQuotaReached=${this.org?.storageQuotaReached}
?orgExecutionMinutesQuotaReached=${this.org?.execMinutesQuotaReached}
@select-new-dialog=${this.onSelectNewDialog}
></btrix-workflows-new>`;
}
@ -575,8 +559,8 @@ export class Org extends LiteElement {
return html`<btrix-workflows-list
.authState=${this.authState!}
orgId=${this.orgId}
?orgStorageQuotaReached=${this.orgStorageQuotaReached}
?orgExecutionMinutesQuotaReached=${this.orgExecutionMinutesQuotaReached}
?orgStorageQuotaReached=${this.org?.storageQuotaReached}
?orgExecutionMinutesQuotaReached=${this.org?.execMinutesQuotaReached}
userId=${this.userInfo!.id}
?isCrawler=${this.isCrawler}
@select-new-dialog=${this.onSelectNewDialog}
@ -686,22 +670,26 @@ export class Org extends LiteElement {
private async onStorageQuotaUpdate(e: CustomEvent<QuotaUpdateDetail>) {
e.stopPropagation();
const { reached } = e.detail;
this.orgStorageQuotaReached = reached;
if (reached) {
this.showStorageQuotaAlert = true;
}
AppStateService.partialUpdateOrg({
id: this.orgId,
storageQuotaReached: reached,
});
}
private async onExecutionMinutesQuotaUpdate(
e: CustomEvent<QuotaUpdateDetail>,
) {
e.stopPropagation();
const { reached } = e.detail;
this.orgExecutionMinutesQuotaReached = reached;
if (reached) {
this.showExecutionMinutesQuotaAlert = true;
}
AppStateService.partialUpdateOrg({
id: this.orgId,
execMinutesQuotaReached: reached,
});
}
private async onUserRoleChange(e: UserRoleChangeEvent) {
@ -723,7 +711,9 @@ export class Org extends LiteElement {
variant: "success",
icon: "check2-circle",
});
this.org = await this.getOrg(this.orgId);
const org = await this.getOrg(this.orgId);
AppStateService.updateOrg(org);
} catch (e) {
console.debug(e);
@ -742,14 +732,14 @@ export class Org extends LiteElement {
}
private async removeMember(member: Member) {
if (!this.org) return;
const org = this.org;
if (!org) return;
const isSelf = member.email === this.userInfo!.email;
if (
isSelf &&
!window.confirm(
msg(
str`Are you sure you want to remove yourself from ${this.org.name}?`,
),
msg(str`Are you sure you want to remove yourself from ${org.name}?`),
)
) {
return;
@ -766,7 +756,7 @@ export class Org extends LiteElement {
this.notify({
message: msg(
str`Successfully removed ${member.name || member.email} from ${
this.org.name
org.name
}.`,
),
variant: "success",
@ -776,7 +766,9 @@ export class Org extends LiteElement {
// FIXME better UX, this is the only page currently that doesn't require org...
this.navTo("/account/settings");
} else {
this.org = await this.getOrg(this.orgId);
const org = await this.getOrg(this.orgId);
AppStateService.updateOrg(org);
}
} catch (e) {
console.debug(e);
@ -794,14 +786,4 @@ export class Org extends LiteElement {
});
}
}
checkStorageQuota() {
this.orgStorageQuotaReached = !!this.org?.storageQuotaReached;
this.showStorageQuotaAlert = this.orgStorageQuotaReached;
}
checkExecutionMinutesQuota() {
this.orgExecutionMinutesQuotaReached = !!this.org?.execMinutesQuotaReached;
this.showExecutionMinutesQuotaAlert = this.orgExecutionMinutesQuotaReached;
}
}

View File

@ -6,6 +6,7 @@ import { locked, options, use } from "lit-shared-state";
import { persist } from "./persist";
import type { AppSettings } from "@/types/app";
import type { OrgData } from "@/types/org";
import type { CurrentUser } from "@/types/user";
export { use };
@ -20,6 +21,7 @@ type SlugLookup = Record<string, string>;
class AppState {
settings: AppSettings | null = null;
userInfo: CurrentUser | null = null;
org: OrgData | null | undefined = undefined;
@options(persist(window.localStorage))
orgSlug: string | null = null;
@ -57,6 +59,23 @@ export class AppStateService {
appState.userInfo = userInfo;
});
};
static updateOrg = (org: AppState["org"]) => {
unlock(() => {
appState.org = org;
});
};
static partialUpdateOrg = (org: { id: string } & Partial<OrgData>) => {
unlock(() => {
if (org.id && appState.org?.id === org.id) {
appState.org = {
...appState.org,
...org,
};
} else {
console.warn("no matching org in app state");
}
});
};
static updateOrgSlug = (orgSlug: AppState["orgSlug"]) => {
unlock(() => {
appState.orgSlug = orgSlug;