From e79f2d014c3fa7a2211ebad1faff06e05e8c7021 Mon Sep 17 00:00:00 2001 From: emma Date: Mon, 15 Sep 2025 15:25:46 -0400 Subject: [PATCH 01/17] wip --- frontend/src/components/orgs-list.ts | 24 +-- .../src/features/admin/org-quota-editor.ts | 179 ++++++++++++++++++ 2 files changed, 192 insertions(+), 11 deletions(-) create mode 100644 frontend/src/features/admin/org-quota-editor.ts diff --git a/frontend/src/components/orgs-list.ts b/frontend/src/components/orgs-list.ts index 991ac6a288..0f26b716b5 100644 --- a/frontend/src/components/orgs-list.ts +++ b/frontend/src/components/orgs-list.ts @@ -28,7 +28,7 @@ import type { Dialog } from "@/components/ui/dialog"; import { ClipboardController } from "@/controllers/clipboard"; import { SubscriptionStatus } from "@/types/billing"; import type { ProxiesAPIResponse, Proxy } from "@/types/crawler"; -import type { OrgData } from "@/utils/orgs"; +import type { OrgData, OrgQuotas } from "@/utils/orgs"; enum OrgFilter { All = "all", @@ -389,6 +389,7 @@ export class OrgsList extends BtrixElement { } private renderOrgQuotas() { + type Keys = keyof OrgQuotas; return html` (this.currOrg = null)} > ${when(this.currOrg?.quotas, (quotas) => - Object.entries(quotas).map(([key, value]) => { - let label; + (Object.entries(quotas) as [Keys, number][]).map(([key, value]) => { + let label: string; switch (key) { case "maxConcurrentCrawls": label = msg("Max Concurrent Crawls"); @@ -421,14 +422,15 @@ export class OrgsList extends BtrixElement { default: label = msg("Unlabeled"); } - return html` `; + return html` ${msg("Current")}: ${value} + `; }), )}
diff --git a/frontend/src/features/admin/org-quota-editor.ts b/frontend/src/features/admin/org-quota-editor.ts new file mode 100644 index 0000000000..93d8652a63 --- /dev/null +++ b/frontend/src/features/admin/org-quota-editor.ts @@ -0,0 +1,179 @@ +import { localized, msg, str } from "@lit/localize"; +import { type SlDialog, type SlInput } from "@shoelace-style/shoelace"; +import { html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { createRef, ref, type Ref } from "lit/directives/ref.js"; +import { when } from "lit/directives/when.js"; +import { type Entries } from "type-fest"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import { type OrgData, type OrgQuotas } from "@/types/org"; + +const LABELS = { + maxConcurrentCrawls: { + label: msg("Max Concurrent Crawls"), + }, + maxPagesPerCrawl: { + label: msg("Max Pages Per Crawl"), + }, + storageQuota: { + label: msg("Storage Quota"), + adjust: (value) => Math.floor(value / 1e9), + }, + maxExecMinutesPerMonth: { + label: msg("Max Execution Minutes Per Month"), + }, + extraExecMinutes: { + label: msg("Extra Execution Minutes"), + }, + giftedExecMinutes: { + label: msg("Gifted Execution Minutes"), + }, +} satisfies { + [key in keyof OrgQuotas]: { + label: string; + adjust?: (value: number) => number; + }; +} as const; + +@customElement("btrix-org-quota-editor") +@localized() +export class OrgQuotaEditor extends BtrixElement { + @property({ type: Object }) + activeOrg: OrgData | null = null; + + @state() + orgQuotaAdjustments: Partial = {}; + + dialog: Ref = createRef(); + + render() { + return html` { + // TODO move to parent; + this.activeOrg = null; + }} + > + ${when(this.activeOrg?.quotas, (quotas) => { + const entries = Object.entries(quotas) as Entries; + return html` + { + const value = + "adjust" in LABELS[key] + ? LABELS[key].adjust(rawValue) + : rawValue; + return [ + LABELS[key].label, + html` + ${this.localize.number(value)} + ${this.orgQuotaAdjustments[key] && + (Math.sign(this.orgQuotaAdjustments[key]) === 1 + ? html`+ + ${this.localize.number(this.orgQuotaAdjustments[key])} = + ${this.localize.number( + value + this.orgQuotaAdjustments[key], + )}` + : html`- + ${this.localize.number(this.orgQuotaAdjustments[key])} = + ${this.localize.number( + value + this.orgQuotaAdjustments[key], + )}`)} + `, + ]; + })} + > + `; + })} + +
+ ${msg("Update Quotas")} + +
+
`; + + // (Object.entries(quotas) as [keyof OrgQuotas, number][]).map( + // ([key, value]) => { + // let label: string; + // switch (key) { + // case "maxConcurrentCrawls": + // label = msg("Max Concurrent Crawls"); + // break; + // case "maxPagesPerCrawl": + // label = msg("Max Pages Per Crawl"); + // break; + // case "storageQuota": + // label = msg("Org Storage Quota (GB)"); + // value = Math.floor(value / 1e9); + // break; + // case "maxExecMinutesPerMonth": + // label = msg("Max Execution Minutes Per Month"); + // break; + // case "extraExecMinutes": + // label = msg("Extra Execution Minutes"); + // break; + // case "giftedExecMinutes": + // label = msg("Gifted Execution Minutes"); + // break; + // default: + // label = msg("Unlabeled"); + // } + // return html` ${msg("Current")}: ${value} + // `; + // }, + // ), + } + + private onUpdateQuota(e: CustomEvent) { + const inputEl = e.target as SlInput; + const name = inputEl.name as keyof OrgData["quotas"]; + const quotas = this.activeOrg?.quotas; + if (quotas) { + if (name === "storageQuota") { + quotas[name] = Number(inputEl.value) * 1e9; + } else { + quotas[name] = Number(inputEl.value); + } + } + } + + private onSubmitQuotas() { + if (this.activeOrg) { + this.dispatchEvent( + new CustomEvent("update-quotas", { + detail: this.activeOrg, + bubbles: true, + composed: true, + }), + ); + + void this.dialog.value?.hide(); + } + } +} From dbf4474df72eaff6e5ddfb0e1bed849dc35bda09 Mon Sep 17 00:00:00 2001 From: emma Date: Mon, 15 Sep 2025 18:43:38 -0400 Subject: [PATCH 02/17] wip 2 --- frontend/src/components/orgs-list.ts | 130 ++++++------- .../components/ui/data-grid/cellDirective.ts | 10 +- .../ui/data-grid/controllers/focus.ts | 9 +- .../components/ui/data-grid/data-grid-cell.ts | 66 +++++-- .../components/ui/data-grid/data-grid-row.ts | 42 +++-- .../src/components/ui/data-grid/data-grid.ts | 61 +++++- frontend/src/components/ui/data-grid/types.ts | 23 ++- frontend/src/features/admin/index.ts | 1 + .../src/features/admin/org-quota-editor.ts | 178 +++++++++++++----- 9 files changed, 362 insertions(+), 158 deletions(-) diff --git a/frontend/src/components/orgs-list.ts b/frontend/src/components/orgs-list.ts index 0f26b716b5..5f7873439b 100644 --- a/frontend/src/components/orgs-list.ts +++ b/frontend/src/components/orgs-list.ts @@ -28,7 +28,7 @@ import type { Dialog } from "@/components/ui/dialog"; import { ClipboardController } from "@/controllers/clipboard"; import { SubscriptionStatus } from "@/types/billing"; import type { ProxiesAPIResponse, Proxy } from "@/types/crawler"; -import type { OrgData, OrgQuotas } from "@/utils/orgs"; +import type { OrgData } from "@/utils/orgs"; enum OrgFilter { All = "all", @@ -389,60 +389,64 @@ export class OrgsList extends BtrixElement { } private renderOrgQuotas() { - type Keys = keyof OrgQuotas; - return html` - (this.currOrg = null)} - > - ${when(this.currOrg?.quotas, (quotas) => - (Object.entries(quotas) as [Keys, number][]).map(([key, value]) => { - let label: string; - switch (key) { - case "maxConcurrentCrawls": - label = msg("Max Concurrent Crawls"); - break; - case "maxPagesPerCrawl": - label = msg("Max Pages Per Crawl"); - break; - case "storageQuota": - label = msg("Org Storage Quota (GB)"); - value = Math.floor(value / 1e9); - break; - case "maxExecMinutesPerMonth": - label = msg("Max Execution Minutes Per Month"); - break; - case "extraExecMinutes": - label = msg("Extra Execution Minutes"); - break; - case "giftedExecMinutes": - label = msg("Gifted Execution Minutes"); - break; - default: - label = msg("Unlabeled"); - } - return html` ${msg("Current")}: ${value} - `; - }), - )} -
- ${msg("Update Quotas")} - -
-
- `; + return html``; + // type Keys = keyof OrgQuotas; + // return html` + // (this.currOrg = null)} + // > + // ${when(this.currOrg?.quotas, (quotas) => + // (Object.entries(quotas) as [Keys, number][]).map(([key, value]) => { + // let label: string; + // switch (key) { + // case "maxConcurrentCrawls": + // label = msg("Max Concurrent Crawls"); + // break; + // case "maxPagesPerCrawl": + // label = msg("Max Pages Per Crawl"); + // break; + // case "storageQuota": + // label = msg("Org Storage Quota (GB)"); + // value = Math.floor(value / 1e9); + // break; + // case "maxExecMinutesPerMonth": + // label = msg("Max Execution Minutes Per Month"); + // break; + // case "extraExecMinutes": + // label = msg("Extra Execution Minutes"); + // break; + // case "giftedExecMinutes": + // label = msg("Gifted Execution Minutes"); + // break; + // default: + // label = msg("Unlabeled"); + // } + // return html` ${msg("Current")}: ${value} + // `; + // }), + // )} + //
+ // ${msg("Update Quotas")} + // + //
+ //
+ // `; } private renderOrgProxies() { @@ -710,15 +714,15 @@ export class OrgsList extends BtrixElement { } } - private onSubmitQuotas() { - if (this.currOrg) { - this.dispatchEvent( - new CustomEvent("update-quotas", { detail: this.currOrg }), - ); + // private onSubmitQuotas() { + // if (this.currOrg) { + // this.dispatchEvent( + // new CustomEvent("update-quotas", { detail: this.currOrg }), + // ); - void this.orgQuotaDialog?.hide(); - } - } + // void this.orgQuotaDialog?.hide(); + // } + // } private onSubmitProxies() { if (this.currOrg) { diff --git a/frontend/src/components/ui/data-grid/cellDirective.ts b/frontend/src/components/ui/data-grid/cellDirective.ts index 615b1e8ee5..bc98fb2087 100644 --- a/frontend/src/components/ui/data-grid/cellDirective.ts +++ b/frontend/src/components/ui/data-grid/cellDirective.ts @@ -1,21 +1,21 @@ import { Directive, type PartInfo } from "lit/directive.js"; import type { DataGridCell } from "./data-grid-cell"; -import type { GridColumn } from "./types"; +import type { GridColumn, GridItem } from "./types"; /** * Directive for replacing `renderCell` and `renderEditCell` * methods with custom render functions. */ -export class CellDirective extends Directive { - private readonly element?: DataGridCell; +export class CellDirective extends Directive { + private readonly element?: DataGridCell; - constructor(partInfo: PartInfo & { element?: DataGridCell }) { + constructor(partInfo: PartInfo & { element?: DataGridCell }) { super(partInfo); this.element = partInfo.element; } - render(col: GridColumn) { + render(col: GridColumn) { if (!this.element) return; if (col.renderCell) { diff --git a/frontend/src/components/ui/data-grid/controllers/focus.ts b/frontend/src/components/ui/data-grid/controllers/focus.ts index 8cdf683497..d491da4d80 100644 --- a/frontend/src/components/ui/data-grid/controllers/focus.ts +++ b/frontend/src/components/ui/data-grid/controllers/focus.ts @@ -9,6 +9,7 @@ import { import type { DataGridCell } from "../data-grid-cell"; import type { DataGridRow } from "../data-grid-row"; +import { type GridItem } from "../types"; type Options = { /** @@ -21,11 +22,13 @@ type Options = { /** * Utilities for managing focus in a data grid. */ -export class DataGridFocusController implements ReactiveController { - readonly #host: DataGridRow | DataGridCell; +export class DataGridFocusController + implements ReactiveController +{ + readonly #host: DataGridRow | DataGridCell; constructor( - host: DataGridRow | DataGridCell, + host: DataGridRow | DataGridCell, opts: Options = { setFocusOnTabbable: false, }, diff --git a/frontend/src/components/ui/data-grid/data-grid-cell.ts b/frontend/src/components/ui/data-grid/data-grid-cell.ts index 1b38b5760b..e45ead6bbf 100644 --- a/frontend/src/components/ui/data-grid/data-grid-cell.ts +++ b/frontend/src/components/ui/data-grid/data-grid-cell.ts @@ -4,14 +4,15 @@ import { html, type TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import get from "lodash/fp/get"; +import { type Primitive } from "type-fest"; import { TableCell } from "../table/table-cell"; import type { GridColumn, + GridColumnNumberType, GridColumnSelectType, GridItem, - GridItemValue, } from "./types"; import { GridColumnType } from "./types"; @@ -31,8 +32,8 @@ const cellInputStyle = [ export type InputElement = SlInput | SlSelect | UrlInput; -export type CellEditEventDetail = { - field: GridColumn["field"]; +export type CellEditEventDetail = { + field: keyof T; value: InputElement["value"]; validity: InputElement["validity"]; validationMessage: InputElement["validationMessage"]; @@ -43,15 +44,17 @@ export type CellEditEventDetail = { * @fires btrix-change CustomEvent */ @customElement("btrix-data-grid-cell") -export class DataGridCell extends TableCell { +export class DataGridCell< + const T extends GridItem = GridItem, +> extends TableCell { @property({ type: Object }) - column?: GridColumn; + column?: GridColumn; @property({ type: Object }) - item?: GridItem; + item?: T; @property({ type: String }) - value?: GridItemValue; + value?: T[keyof T]; @property({ type: Boolean }) editable = false; @@ -68,7 +71,7 @@ export class DataGridCell extends TableCell { @property({ type: Number, reflect: true }) tabindex = 0; - readonly #focus = new DataGridFocusController(this, { + readonly #focus = new DataGridFocusController(this, { setFocusOnTabbable: true, }); @@ -88,7 +91,7 @@ export class DataGridCell extends TableCell { if (!this.column) return null; return this.shadowRoot!.querySelector( - `[name=${this.column.field}]`, + `[name=${String(this.column.field)}]`, ); } @@ -116,10 +119,10 @@ export class DataGridCell extends TableCell { return this.renderEditCell({ item: this.item, value: this.value }); } - return this.renderCell({ item: this.item }); + return html`${this.renderCell({ item: this.item })}`; } - renderCell = ({ item }: { item: GridItem }) => { + renderCell = ({ item }: { item: T }): string | TemplateResult<1> => { return html`${(this.column && get(this.column.field, item)) ?? ""}`; }; @@ -127,9 +130,9 @@ export class DataGridCell extends TableCell { item, value: cellValue, }: { - item: GridItem; - value?: GridItemValue; - }) => { + item: T; + value?: T[keyof T]; + }): TemplateResult<1> => { const col = this.column; if (!col) return html``; @@ -141,7 +144,7 @@ export class DataGridCell extends TableCell { return html`
`; + case GridColumnType.Number: { + const { min, max, step } = col as GridColumnNumberType; + return html``; + } default: break; } return html` ( + maybeFn: U | ((item: T | undefined) => U), + ) { + if (typeof maybeFn === "function") { + return maybeFn(this.item); + } + return maybeFn; + } + private readonly onInput = (e: Event) => { if (!this.column) return; @@ -194,7 +220,7 @@ export class DataGridCell extends TableCell { const input = e.target as InputElement; this.dispatchEvent( - new CustomEvent("btrix-input", { + new CustomEvent>("btrix-input", { detail: { field: this.column.field, value: input.value, @@ -215,7 +241,7 @@ export class DataGridCell extends TableCell { const input = e.target as InputElement; this.dispatchEvent( - new CustomEvent("btrix-change", { + new CustomEvent>("btrix-change", { detail: { field: this.column.field, value: input.value, diff --git a/frontend/src/components/ui/data-grid/data-grid-row.ts b/frontend/src/components/ui/data-grid/data-grid-row.ts index 3ab858fb99..e1e0dd9882 100644 --- a/frontend/src/components/ui/data-grid/data-grid-row.ts +++ b/frontend/src/components/ui/data-grid/data-grid-row.ts @@ -23,6 +23,10 @@ import { tw } from "@/utils/tailwind"; export type RowRemoveEventDetail = { key?: string; }; +export type RowEditEventDetail = + CellEditEventDetail & { + rowKey?: string; + }; const cell = directive(CellDirective); @@ -31,15 +35,18 @@ const editableCellStyle = tw`p-0 focus-visible:bg-slate-50 `; /** * @fires btrix-remove CustomEvent + * @fires btrix-input CustomEvent */ @customElement("btrix-data-grid-row") @localized() -export class DataGridRow extends FormControl(TableRow) { +export class DataGridRow< + const T extends GridItem = GridItem, +> extends FormControl(TableRow) { /** * Set of columns. */ @property({ type: Array }) - columns?: GridColumn[] = []; + columns?: GridColumn[] = []; /** * Row key/ID. @@ -51,7 +58,7 @@ export class DataGridRow extends FormControl(TableRow) { * Data to be presented as a row. */ @property({ type: Object, hasChanged: (a, b) => !isEqual(a, b) }) - item?: GridItem; + item?: T; /** * Whether the row can be removed. @@ -93,12 +100,12 @@ export class DataGridRow extends FormControl(TableRow) { private expanded = false; @state() - private cellValues: Partial = {}; + private cellValues: Partial = {}; readonly #focus = new DataGridFocusController(this); readonly #invalidInputsMap = new Map< - GridColumn["field"], + GridColumn["field"], InputElement["validationMessage"] >(); @@ -135,11 +142,11 @@ export class DataGridRow extends FormControl(TableRow) { } @queryAll("btrix-data-grid-cell") - private readonly gridCells?: NodeListOf; + private readonly gridCells?: NodeListOf>; - private setValue(cellValues: Partial) { + private setValue(cellValues: Partial) { Object.keys(cellValues).forEach((field) => { - this.cellValues[field] = cellValues[field]; + (this.cellValues[field] as T[keyof T] | undefined) = cellValues[field]; }); this.setFormValue(JSON.stringify(this.cellValues)); @@ -211,7 +218,7 @@ export class DataGridRow extends FormControl(TableRow) { renderDetails = (_row: { item: GridItem }) => html``; - private readonly renderCell = (col: GridColumn, i: number) => { + private readonly renderCell = (col: GridColumn, i: number) => { const item = this.item; if (!item) return; @@ -252,7 +259,7 @@ export class DataGridRow extends FormControl(TableRow) { .item=${item} value=${ifDefined(this.cellValues[col.field] ?? undefined)} ?editable=${editable} - ${cell(col)} + ${cell(col as GridColumn)} @keydown=${this.onKeydown} > @@ -276,7 +283,7 @@ export class DataGridRow extends FormControl(TableRow) { } const gridCells = Array.from(this.gridCells); - const i = gridCells.indexOf(e.target as DataGridCell); + const i = gridCells.indexOf(e.target as DataGridCell); if (i === -1) return; @@ -335,6 +342,17 @@ export class DataGridRow extends FormControl(TableRow) { ) => { e.stopPropagation(); + this.dispatchEvent( + new CustomEvent("btrix-input", { + detail: { + ...e.detail, + rowKey: this.key, + }, + bubbles: true, + composed: true, + }), + ); + const { field, value, validity, validationMessage } = e.detail; const tableCell = e.target as DataGridCell; @@ -347,7 +365,7 @@ export class DataGridRow extends FormControl(TableRow) { this.setValue({ [field]: value.toString(), - }); + } as Partial); }; private readonly onCellChange = async ( diff --git a/frontend/src/components/ui/data-grid/data-grid.ts b/frontend/src/components/ui/data-grid/data-grid.ts index b05d17de55..a0f529828d 100644 --- a/frontend/src/components/ui/data-grid/data-grid.ts +++ b/frontend/src/components/ui/data-grid/data-grid.ts @@ -32,20 +32,22 @@ const styles = unsafeCSS(stylesheet); */ @customElement("btrix-data-grid") @localized() -export class DataGrid extends TailwindElement { +export class DataGrid< + const T extends GridItem = GridItem, +> extends TailwindElement { static styles = styles; /** * Set of columns. */ @property({ type: Array }) - columns?: GridColumn[]; + columns?: GridColumn[]; /** * Set of data to be presented as rows. Omit if using the `rows` slot. */ @property({ type: Array }) - items?: GridItem[]; + items?: T[]; /** * Stick header row to the top of the table or the viewport. @@ -60,7 +62,7 @@ export class DataGrid extends TailwindElement { * Defaults to one generated by nanoid. */ @property({ type: String }) - rowKey?: string; + rowKey?: keyof T; /** * Whether rows can be selected, firing a `btrix-select-row` event. @@ -398,7 +400,56 @@ export class DataGrid extends TailwindElement { assignProp(el, { name: "removable", value: removable }); assignProp(el, { name: "editCells", value: editCells }); - (el as DataGridRow)["columns"] = this.columns; + (el as DataGridRow)["columns"] = this.columns; }); }; } + +export const dataGrid = ( + props: Pick< + DataGrid, + | "columns" + | "items" + | "stickyHeader" + | "rowKey" + | "rowsSelectable" + | "selectMode" + | "rowsExpandable" + | "rowsRemovable" + | "rowsAddible" + | "alignRows" + | "addRowsInputValue" + | "editCells" + | "disabled" + | "defaultItem" + | "formControlLabel" + | "formControlLabelId" + | "rowsController" + >, +) => { + const { columns, items, stickyHeader, rowKey, rowsSelectable, selectMode } = + props; + + return html` + + + `; +}; diff --git a/frontend/src/components/ui/data-grid/types.ts b/frontend/src/components/ui/data-grid/types.ts index 6e2c271f05..9d00e1852b 100644 --- a/frontend/src/components/ui/data-grid/types.ts +++ b/frontend/src/components/ui/data-grid/types.ts @@ -3,7 +3,7 @@ import { z } from "zod"; export type GridItem = Record< T, - string | number | null | undefined + string | number | TemplateResult<1> | null | undefined >; export type GridItemValue = @@ -25,8 +25,19 @@ export type GridColumnSelectType = { }[]; }; -export type GridColumn = { - field: T; +export type GridColumnNumberType = { + inputType: GridColumnType.Number; + min?: number | ((item: Item | undefined) => number | undefined); + max?: number | ((item: Item | undefined) => number | undefined); + step?: number | ((item: Item | undefined) => number | undefined); +}; + +export type GridColumn< + Item = GridItem, + Key extends keyof Item = keyof Item, + InputType extends GridColumnType = GridColumnType, +> = { + field: Key; label: string | TemplateResult; description?: string; editable?: boolean; @@ -38,13 +49,15 @@ export type GridColumn = { item: Item; value?: Item[keyof Item]; }) => TemplateResult<1>; - renderCell?: (props: { item: Item }) => TemplateResult<1>; - renderCellTooltip?: (props: { item: Item }) => TemplateResult<1>; + renderCell?: (props: { item: Item }) => string | TemplateResult<1>; + renderCellTooltip?: (props: { item: Item }) => string | TemplateResult<1>; + inputType?: InputType; } & ( | { inputType?: GridColumnType; } | GridColumnSelectType + | GridColumnNumberType ); const rowIdSchema = z.string().nanoid(); diff --git a/frontend/src/features/admin/index.ts b/frontend/src/features/admin/index.ts index cb10e490c3..552254cc72 100644 --- a/frontend/src/features/admin/index.ts +++ b/frontend/src/features/admin/index.ts @@ -1,3 +1,4 @@ import "./active-crawls-badge"; import "./stats"; import "./super-admin-banner"; +import "./org-quota-editor"; diff --git a/frontend/src/features/admin/org-quota-editor.ts b/frontend/src/features/admin/org-quota-editor.ts index 93d8652a63..bbcfd63341 100644 --- a/frontend/src/features/admin/org-quota-editor.ts +++ b/frontend/src/features/admin/org-quota-editor.ts @@ -1,40 +1,53 @@ import { localized, msg, str } from "@lit/localize"; import { type SlDialog, type SlInput } from "@shoelace-style/shoelace"; -import { html } from "lit"; +import { html, type PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { createRef, ref, type Ref } from "lit/directives/ref.js"; import { when } from "lit/directives/when.js"; +import { isEqual } from "lodash"; import { type Entries } from "type-fest"; import { BtrixElement } from "@/classes/BtrixElement"; -import { type OrgData, type OrgQuotas } from "@/types/org"; +import { type RowEditEventDetail } from "@/components/ui/data-grid/data-grid-row"; +import { + GridColumnType, + type GridColumn, +} from "@/components/ui/data-grid/types"; +import { type OrgData, type OrgQuotas } from "@/utils/orgs"; const LABELS = { maxConcurrentCrawls: { label: msg("Max Concurrent Crawls"), + type: "number", }, maxPagesPerCrawl: { label: msg("Max Pages Per Crawl"), + type: "number", }, storageQuota: { label: msg("Storage Quota"), - adjust: (value) => Math.floor(value / 1e9), + type: "bytes", + scale: 1e9, }, maxExecMinutesPerMonth: { label: msg("Max Execution Minutes Per Month"), + type: "number", }, extraExecMinutes: { label: msg("Extra Execution Minutes"), + type: "number", }, giftedExecMinutes: { label: msg("Gifted Execution Minutes"), + type: "number", }, -} satisfies { +} as const satisfies { [key in keyof OrgQuotas]: { label: string; - adjust?: (value: number) => number; + type: "number" | "bytes"; + scale?: number; }; -} as const; +}; @customElement("btrix-org-quota-editor") @localized() @@ -42,61 +55,136 @@ export class OrgQuotaEditor extends BtrixElement { @property({ type: Object }) activeOrg: OrgData | null = null; - @state() + @state({ hasChanged: (a, b) => !isEqual(a, b) }) orgQuotaAdjustments: Partial = {}; dialog: Ref = createRef(); + show() { + void this.dialog.value?.show(); + } + + hide() { + this.orgQuotaAdjustments = {}; + void this.dialog.value?.hide(); + } + + willUpdate(changedProperties: PropertyValues) { + if (changedProperties.has("activeOrg")) { + this.orgQuotaAdjustments = {}; + } + } + render() { return html` { // TODO move to parent; - this.activeOrg = null; + this.orgQuotaAdjustments = {}; }} > ${when(this.activeOrg?.quotas, (quotas) => { const entries = Object.entries(quotas) as Entries; + const items = entries.map(([key, value]) => { + const labelConfig = LABELS[key]; + let currentAdjustment = this.orgQuotaAdjustments[key] || 0; + if (labelConfig.type === "bytes") { + currentAdjustment = Math.floor( + currentAdjustment / labelConfig.scale, + ); + } + return { + key: key, + current: value, + adjustment: currentAdjustment, + }; + }); + type Item = (typeof items)[number]; + const columns: GridColumn[] = [ + { + label: msg("Key"), + field: "key", + editable: false, + width: "2fr", + renderCell: ({ item }) => html`${LABELS[item.key].label}`, + }, + { + label: msg("Current Value"), + field: "current", + editable: false, + width: "1fr", + renderCell: ({ item }) => { + const key = item.key; + const value = item.current; + const labelConfig = LABELS[key]; + const format = (v: number, isInitialValue = true) => { + if (v <= 0) + return isInitialValue + ? html`${msg("Unlimited")}` + : html`${msg("Unset")}`; + const fn = + labelConfig.type === "bytes" + ? this.localize.bytes + : this.localize.number; + return fn(v); + }; + + return this.orgQuotaAdjustments[key] + ? Math.sign(this.orgQuotaAdjustments[key]) === 1 + ? html` + ${format(value + this.orgQuotaAdjustments[key])} + (${format(value, false)} + + + ${format(this.orgQuotaAdjustments[key])})` + : html` + ${format(value + this.orgQuotaAdjustments[key])} + (${format(value, false)} + + - ${format(-this.orgQuotaAdjustments[key])})` + : format(value); + }, + }, + { + label: msg("Adjustment"), + field: "adjustment", + editable: true, + width: "1fr", + inputType: GridColumnType.Number, + min: (item) => -1 * (item?.current ?? 0), + step: 1, + }, + ]; return html` { - const value = - "adjust" in LABELS[key] - ? LABELS[key].adjust(rawValue) - : rawValue; - return [ - LABELS[key].label, - html` - ${this.localize.number(value)} - ${this.orgQuotaAdjustments[key] && - (Math.sign(this.orgQuotaAdjustments[key]) === 1 - ? html`+ - ${this.localize.number(this.orgQuotaAdjustments[key])} = - ${this.localize.number( - value + this.orgQuotaAdjustments[key], - )}` - : html`- - ${this.localize.number(this.orgQuotaAdjustments[key])} = - ${this.localize.number( - value + this.orgQuotaAdjustments[key], - )}`)} - `, - ]; - })} + editCells + .columns=${columns} + rowKey="key" + .items=${items} + @btrix-input=${(event: CustomEvent>) => { + const key = event.detail.rowKey as keyof OrgQuotas; + let value = Number(event.detail.value); + const labelConfig = LABELS[key]; + if (labelConfig.type === "bytes") { + value = Math.floor(value * labelConfig.scale); + } + this.orgQuotaAdjustments = { + ...this.orgQuotaAdjustments, + [key]: value, + }; + }} > `; })} From 80cb91d8ad17837ca53b03be925fabd52dc6a64d Mon Sep 17 00:00:00 2001 From: emma Date: Mon, 15 Sep 2025 20:00:13 -0400 Subject: [PATCH 03/17] add change tracking --- .../src/features/admin/org-quota-editor.ts | 263 +++++++++++++----- frontend/src/utils/pluralize.ts | 26 ++ 2 files changed, 212 insertions(+), 77 deletions(-) diff --git a/frontend/src/features/admin/org-quota-editor.ts b/frontend/src/features/admin/org-quota-editor.ts index bbcfd63341..5decbe6c26 100644 --- a/frontend/src/features/admin/org-quota-editor.ts +++ b/frontend/src/features/admin/org-quota-editor.ts @@ -1,5 +1,5 @@ import { localized, msg, str } from "@lit/localize"; -import { type SlDialog, type SlInput } from "@shoelace-style/shoelace"; +import { type SlDialog } from "@shoelace-style/shoelace"; import { html, type PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { createRef, ref, type Ref } from "lit/directives/ref.js"; @@ -14,6 +14,81 @@ import { type GridColumn, } from "@/components/ui/data-grid/types"; import { type OrgData, type OrgQuotas } from "@/utils/orgs"; +import { pluralOf } from "@/utils/pluralize"; + +const PRESETS = { + Starter: { + quotas: { + maxConcurrentCrawls: 1, + maxPagesPerCrawl: 2000, + storageQuota: 100_000_000_000, + maxExecMinutesPerMonth: 180, + }, + subscriptionIds: ["starter", "starterTest"], + }, + Standard: { + quotas: { + maxConcurrentCrawls: 2, + maxPagesPerCrawl: 5000, + storageQuota: 220_000_000_000, + maxExecMinutesPerMonth: 360, + }, + subscriptionIds: ["standard", "standardTest"], + }, + Plus: { + quotas: { + maxConcurrentCrawls: 3, + maxPagesPerCrawl: 10000, + storageQuota: 500_000_000_000, + maxExecMinutesPerMonth: 720, + }, + subscriptionIds: ["plus", "plusTest"], + }, + "Pro Standard": { + quotas: { + maxConcurrentCrawls: 4, + maxPagesPerCrawl: 50_000, + storageQuota: 1_000_000_000_000, + maxExecMinutesPerMonth: 50 * 60, + }, + subscriptionIds: ["pro-standard-monthly", "pro-standard-yearly"], + }, + "Pro Teams": { + quotas: { + maxConcurrentCrawls: 5, + maxPagesPerCrawl: 100_000, + storageQuota: 3_000_000_000_000, + maxExecMinutesPerMonth: 80 * 60, + }, + subscriptionIds: ["pro-teams-monthly", "pro-teams-yearly"], + }, + "Pro Plus": { + quotas: { + maxConcurrentCrawls: 10, + maxPagesPerCrawl: 400_000, + storageQuota: 5_000_000_000_000, + maxExecMinutesPerMonth: 150 * 60, + }, + subscriptionIds: ["pro-plus-monthly", "pro-plus-yearly"], + }, + Unset: { + quotas: { + maxConcurrentCrawls: 0, + maxPagesPerCrawl: 0, + storageQuota: 0, + maxExecMinutesPerMonth: 0, + }, + subscriptionIds: [], + }, +} as const satisfies Record< + string, + { + quotas: { + [key in keyof OrgQuotas]?: number; + }; + subscriptionIds?: string[]; + } +>; const LABELS = { maxConcurrentCrawls: { @@ -76,6 +151,9 @@ export class OrgQuotaEditor extends BtrixElement { } render() { + const changeCount = Object.values(this.orgQuotaAdjustments).filter( + (value) => !!value, + ).length; return html` [] = [ { - label: msg("Key"), + label: msg("Quota"), field: "key", editable: false, width: "2fr", renderCell: ({ item }) => html`${LABELS[item.key].label}`, + align: "start", }, { - label: msg("Current Value"), + label: msg("Value"), field: "current", editable: false, width: "1fr", @@ -118,39 +197,26 @@ export class OrgQuotaEditor extends BtrixElement { const key = item.key; const value = item.current; const labelConfig = LABELS[key]; - const format = (v: number, isInitialValue = true) => { - if (v <= 0) - return isInitialValue - ? html`${msg("Unlimited")}` - : html`${msg("Unset")}`; - const fn = - labelConfig.type === "bytes" - ? this.localize.bytes - : this.localize.number; - return fn(v); - }; + const format = (v: number, isInitialValue = true) => + this.format(v, labelConfig.type, isInitialValue); return this.orgQuotaAdjustments[key] ? Math.sign(this.orgQuotaAdjustments[key]) === 1 - ? html` + ? html` ${format(value + this.orgQuotaAdjustments[key])} - (${format(value, false)} - - + ${format(this.orgQuotaAdjustments[key])} + (${format(value, false)} + + + ${format(this.orgQuotaAdjustments[key])})` - : html` + : html` ${format(value + this.orgQuotaAdjustments[key])} - (${format(value, false)} - - - ${format(-this.orgQuotaAdjustments[key])} + (${format(value, false)} + + - ${format(-this.orgQuotaAdjustments[key])})` @@ -167,7 +233,78 @@ export class OrgQuotaEditor extends BtrixElement { step: 1, }, ]; + return html` + + + + ${(Object.entries(PRESETS) as Entries).map( + ([key, value]) => { + const isCurrentSubscription = ( + value.subscriptionIds as string[] + ).includes(this.activeOrg?.subscription?.planId ?? ""); + return html` + { + const newQuota: Partial = {}; + ( + Object.entries(value.quotas) as Entries< + typeof value.quotas + > + ).forEach(([k, v]) => { + newQuota[k] = v - quotas[k]; + }); + this.orgQuotaAdjustments = { ...newQuota }; + }} + variant=${isCurrentSubscription ? "primary" : "default"} + > + ${key} + ${isCurrentSubscription + ? html`` + : null} + +
+
+ ${key}${isCurrentSubscription + ? html` - + ${msg("This is the current subscription.")}` + : null} +
+ +
+ + + ${( + Object.entries(value.quotas) as Entries< + typeof value.quotas + > + ).map( + ([key, value]) => html` + + + + + `, + )} + +
${LABELS[key].label} + ${this.format(value, LABELS[key].type)} +
+
+
`; + }, + )} +
+
+
+ ${this.localize.number(changeCount)} + ${pluralOf("changes", changeCount)} +
${msg("Update Quotas")}
`; - - // (Object.entries(quotas) as [keyof OrgQuotas, number][]).map( - // ([key, value]) => { - // let label: string; - // switch (key) { - // case "maxConcurrentCrawls": - // label = msg("Max Concurrent Crawls"); - // break; - // case "maxPagesPerCrawl": - // label = msg("Max Pages Per Crawl"); - // break; - // case "storageQuota": - // label = msg("Org Storage Quota (GB)"); - // value = Math.floor(value / 1e9); - // break; - // case "maxExecMinutesPerMonth": - // label = msg("Max Execution Minutes Per Month"); - // break; - // case "extraExecMinutes": - // label = msg("Extra Execution Minutes"); - // break; - // case "giftedExecMinutes": - // label = msg("Gifted Execution Minutes"); - // break; - // default: - // label = msg("Unlabeled"); - // } - // return html` ${msg("Current")}: ${value} - // `; - // }, - // ), } - private onUpdateQuota(e: CustomEvent) { - const inputEl = e.target as SlInput; - const name = inputEl.name as keyof OrgData["quotas"]; - const quotas = this.activeOrg?.quotas; - if (quotas) { - if (name === "storageQuota") { - quotas[name] = Number(inputEl.value) * 1e9; - } else { - quotas[name] = Number(inputEl.value); - } - } + private format(v: number, type: "bytes" | "number", isInitialValue = true) { + if (v <= 0) + return isInitialValue + ? html`${msg("Unset")}` + : html`${msg("0")}`; + const fn = type === "bytes" ? this.localize.bytes : this.localize.number; + return fn(v); } private onSubmitQuotas() { if (this.activeOrg) { + ( + Object.entries(this.orgQuotaAdjustments) as Entries< + typeof this.orgQuotaAdjustments + > + ) + .filter(Boolean) + .forEach(([key, value]) => { + this.activeOrg!.quotas[key] += value ?? 0; + }); this.dispatchEvent( new CustomEvent("update-quotas", { detail: this.activeOrg, diff --git a/frontend/src/utils/pluralize.ts b/frontend/src/utils/pluralize.ts index 6f5bfc8cd1..dcfe3fe369 100644 --- a/frontend/src/utils/pluralize.ts +++ b/frontend/src/utils/pluralize.ts @@ -247,6 +247,32 @@ const plurals = { id: "profiles.plural.other", }), }, + changes: { + zero: msg("changes", { + desc: 'plural form of "changes" for zero changes', + id: "changes.plural.zero", + }), + one: msg("change", { + desc: 'singular form for "change"', + id: "changes.plural.one", + }), + two: msg("changes", { + desc: 'plural form of "changes" for two changes', + id: "changes.plural.two", + }), + few: msg("changes", { + desc: 'plural form of "changes" for few changes', + id: "changes.plural.few", + }), + many: msg("changes", { + desc: 'plural form of "changes" for many changes', + id: "changes.plural.many", + }), + other: msg("changes", { + desc: 'plural form of "changes" for multiple/other changes', + id: "changes.plural.other", + }), + }, }; export const pluralOf = (word: keyof typeof plurals, count: number) => { From 8c4dcc3b8b09410ef72705361dcff570eeeccbf1 Mon Sep 17 00:00:00 2001 From: emma Date: Mon, 15 Sep 2025 20:06:19 -0400 Subject: [PATCH 04/17] fix types --- frontend/src/features/org/usage-history-table.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/org/usage-history-table.ts b/frontend/src/features/org/usage-history-table.ts index 45f76c35a7..9aef8702fc 100644 --- a/frontend/src/features/org/usage-history-table.ts +++ b/frontend/src/features/org/usage-history-table.ts @@ -16,6 +16,8 @@ enum Field { GiftedExecutionTime = "giftedExecutionTime", } +type Item = Record<`${Field}`, number>; + @customElement("btrix-usage-history-table") @localized() export class UsageHistoryTable extends BtrixElement { @@ -46,7 +48,7 @@ export class UsageHistoryTable extends BtrixElement { `; } - const cols: GridColumn[] = [ + const cols: GridColumn[] = [ { field: Field.Month, label: msg("Month"), @@ -165,7 +167,7 @@ export class UsageHistoryTable extends BtrixElement { } private readonly renderSecondsForField = - (field: Field) => + (field: `${Field}`) => ({ item }: { item: GridItem }) => html` ${item[field] ? humanizeExecutionSeconds(+item[field]) : noData} `; From e905a16dd2b4440d7eead933f95fd72ffe6cd882 Mon Sep 17 00:00:00 2001 From: emma Date: Mon, 15 Sep 2025 20:42:38 -0400 Subject: [PATCH 05/17] clean up --- frontend/src/components/orgs-list.ts | 54 ------------------- .../src/components/ui/data-grid/data-grid.ts | 49 ----------------- frontend/src/components/ui/data-grid/types.ts | 2 +- .../src/features/admin/org-quota-editor.ts | 5 +- 4 files changed, 3 insertions(+), 107 deletions(-) diff --git a/frontend/src/components/orgs-list.ts b/frontend/src/components/orgs-list.ts index 5f7873439b..a5a57e0b50 100644 --- a/frontend/src/components/orgs-list.ts +++ b/frontend/src/components/orgs-list.ts @@ -393,60 +393,6 @@ export class OrgsList extends BtrixElement { id="orgQuotaDialog" .activeOrg=${this.currOrg} >`; - // type Keys = keyof OrgQuotas; - // return html` - // (this.currOrg = null)} - // > - // ${when(this.currOrg?.quotas, (quotas) => - // (Object.entries(quotas) as [Keys, number][]).map(([key, value]) => { - // let label: string; - // switch (key) { - // case "maxConcurrentCrawls": - // label = msg("Max Concurrent Crawls"); - // break; - // case "maxPagesPerCrawl": - // label = msg("Max Pages Per Crawl"); - // break; - // case "storageQuota": - // label = msg("Org Storage Quota (GB)"); - // value = Math.floor(value / 1e9); - // break; - // case "maxExecMinutesPerMonth": - // label = msg("Max Execution Minutes Per Month"); - // break; - // case "extraExecMinutes": - // label = msg("Extra Execution Minutes"); - // break; - // case "giftedExecMinutes": - // label = msg("Gifted Execution Minutes"); - // break; - // default: - // label = msg("Unlabeled"); - // } - // return html` ${msg("Current")}: ${value} - // `; - // }), - // )} - //
- // ${msg("Update Quotas")} - // - //
- //
- // `; } private renderOrgProxies() { diff --git a/frontend/src/components/ui/data-grid/data-grid.ts b/frontend/src/components/ui/data-grid/data-grid.ts index a0f529828d..636232aa66 100644 --- a/frontend/src/components/ui/data-grid/data-grid.ts +++ b/frontend/src/components/ui/data-grid/data-grid.ts @@ -404,52 +404,3 @@ export class DataGrid< }); }; } - -export const dataGrid = ( - props: Pick< - DataGrid, - | "columns" - | "items" - | "stickyHeader" - | "rowKey" - | "rowsSelectable" - | "selectMode" - | "rowsExpandable" - | "rowsRemovable" - | "rowsAddible" - | "alignRows" - | "addRowsInputValue" - | "editCells" - | "disabled" - | "defaultItem" - | "formControlLabel" - | "formControlLabelId" - | "rowsController" - >, -) => { - const { columns, items, stickyHeader, rowKey, rowsSelectable, selectMode } = - props; - - return html` - - - `; -}; diff --git a/frontend/src/components/ui/data-grid/types.ts b/frontend/src/components/ui/data-grid/types.ts index 9d00e1852b..ac87d34482 100644 --- a/frontend/src/components/ui/data-grid/types.ts +++ b/frontend/src/components/ui/data-grid/types.ts @@ -3,7 +3,7 @@ import { z } from "zod"; export type GridItem = Record< T, - string | number | TemplateResult<1> | null | undefined + string | number | null | undefined >; export type GridItemValue = diff --git a/frontend/src/features/admin/org-quota-editor.ts b/frontend/src/features/admin/org-quota-editor.ts index 5decbe6c26..f920b4e1c8 100644 --- a/frontend/src/features/admin/org-quota-editor.ts +++ b/frontend/src/features/admin/org-quota-editor.ts @@ -260,13 +260,12 @@ export class OrgQuotaEditor extends BtrixElement { }); this.orgQuotaAdjustments = { ...newQuota }; }} - variant=${isCurrentSubscription ? "primary" : "default"} > ${key} ${isCurrentSubscription ? html`` : null} From 3727f6716208a65f058b8322af2a0fefcfa19afa Mon Sep 17 00:00:00 2001 From: emma Date: Mon, 15 Sep 2025 20:45:56 -0400 Subject: [PATCH 06/17] add note about presets --- frontend/src/features/admin/org-quota-editor.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/features/admin/org-quota-editor.ts b/frontend/src/features/admin/org-quota-editor.ts index f920b4e1c8..8eb994edd4 100644 --- a/frontend/src/features/admin/org-quota-editor.ts +++ b/frontend/src/features/admin/org-quota-editor.ts @@ -16,6 +16,7 @@ import { import { type OrgData, type OrgQuotas } from "@/utils/orgs"; import { pluralOf } from "@/utils/pluralize"; +// These were manually copied over from Cashew on 2025-09-15 — please update if necessary const PRESETS = { Starter: { quotas: { From 2b6d7d1eb78cc4eba414325fb404b3676877c003 Mon Sep 17 00:00:00 2001 From: emma Date: Tue, 23 Sep 2025 14:38:25 -0400 Subject: [PATCH 07/17] add plans endpoint to backend --- backend/btrixcloud/models.py | 19 +++++++++++++++++++ backend/btrixcloud/orgs.py | 16 ++++++++++++++++ chart/templates/configmap.yaml | 1 + 3 files changed, 36 insertions(+) diff --git a/backend/btrixcloud/models.py b/backend/btrixcloud/models.py index 3c511f601b..254633d045 100644 --- a/backend/btrixcloud/models.py +++ b/backend/btrixcloud/models.py @@ -1849,6 +1849,25 @@ class OrgQuotasIn(BaseModel): extraExecMinutes: Optional[int] = None giftedExecMinutes: Optional[int] = None +# ============================================================================ +class Plan(BaseModel): + """Available Browsertrix plan, from env""" + + id: str + name: str + org_quotas: OrgQuotas + stripe_product_ids: list[str] = [] + stripe_price_ids: list[str] = [] + stripe_portal_config_id: str | None = None + hubspot_product_id: str | None = None + hubspot_deal_amount: float | None = None + testmode: bool = False + +# ============================================================================ +class PlansResponse(BaseModel): + """Response for plans api endpoint""" + + plans: list[Plan] # ============================================================================ class SubscriptionEventOut(BaseModel): diff --git a/backend/btrixcloud/orgs.py b/backend/btrixcloud/orgs.py index 3d549c6c17..63c405e71b 100644 --- a/backend/btrixcloud/orgs.py +++ b/backend/btrixcloud/orgs.py @@ -14,6 +14,7 @@ from typing import Optional, TYPE_CHECKING, Dict, Callable, List, AsyncGenerator, Any +from pydantic import ValidationError from pymongo import ReturnDocument from pymongo.collation import Collation from pymongo.errors import AutoReconnect, DuplicateKeyError @@ -29,6 +30,7 @@ WAITING_STATES, BaseCrawl, Organization, + PlansResponse, StorageRef, OrgQuotas, OrgQuotasIn, @@ -1615,6 +1617,20 @@ async def rename_org( return {"updated": True} + @router.get("/plans", tags=["settings"], response_model=PlansResponse) + async def get_plans(user: User = Depends(user_dep)) -> PlansResponse: + if not user.is_superuser: + raise HTTPException(status_code=403, detail="Not Allowed") + plans_json = os.environ.get("BTRIX_PLANS") + if not plans_json: + print("Info: Plans not configured") + return PlansResponse(plans=[]) + try: + plans = PlansResponse.model_validate_json(plans_json) + return plans + except ValidationError as err: + raise HTTPException(status_code=400, detail="invalid_plans") from err + @router.post("/quotas", tags=["organizations"], response_model=UpdatedResponse) async def update_quotas( quotas: OrgQuotasIn, diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index 7fd35cd755..ae7c6b16af 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -107,6 +107,7 @@ data: CLEANUP_FILES_AFTER_MINUTES: "{{ .Values.cleanup_files_after_minutes | default 1440 }}" + BTRIX_PLANS: "{{ .Values.available_plans }}" --- apiVersion: v1 From f5a8ea2b6d0a89e068d834a45b577b66bd3985d8 Mon Sep 17 00:00:00 2001 From: emma Date: Tue, 23 Sep 2025 15:47:49 -0400 Subject: [PATCH 08/17] remove static presets & fetch from backend instead --- backend/btrixcloud/models.py | 5 - backend/btrixcloud/orgs.py | 7 +- chart/templates/configmap.yaml | 2 +- chart/values.yaml | 1 - .../src/features/admin/org-quota-editor.ts | 217 +++++++----------- 5 files changed, 90 insertions(+), 142 deletions(-) diff --git a/backend/btrixcloud/models.py b/backend/btrixcloud/models.py index 254633d045..c038e2cc99 100644 --- a/backend/btrixcloud/models.py +++ b/backend/btrixcloud/models.py @@ -1856,11 +1856,6 @@ class Plan(BaseModel): id: str name: str org_quotas: OrgQuotas - stripe_product_ids: list[str] = [] - stripe_price_ids: list[str] = [] - stripe_portal_config_id: str | None = None - hubspot_product_id: str | None = None - hubspot_deal_amount: float | None = None testmode: bool = False # ============================================================================ diff --git a/backend/btrixcloud/orgs.py b/backend/btrixcloud/orgs.py index 63c405e71b..bed53bc3d8 100644 --- a/backend/btrixcloud/orgs.py +++ b/backend/btrixcloud/orgs.py @@ -1617,11 +1617,12 @@ async def rename_org( return {"updated": True} - @router.get("/plans", tags=["settings"], response_model=PlansResponse) - async def get_plans(user: User = Depends(user_dep)) -> PlansResponse: + @app.get("/orgs/plans", tags=["organizations"], response_model=PlansResponse) + async def get_plans(user: User = Depends(user_dep)): if not user.is_superuser: raise HTTPException(status_code=403, detail="Not Allowed") - plans_json = os.environ.get("BTRIX_PLANS") + plans_json = os.environ.get("AVAILABLE_PLANS") + print("DEBUG plans_json:", plans_json) if not plans_json: print("Info: Plans not configured") return PlansResponse(plans=[]) diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index ae7c6b16af..15ccd728aa 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -107,7 +107,7 @@ data: CLEANUP_FILES_AFTER_MINUTES: "{{ .Values.cleanup_files_after_minutes | default 1440 }}" - BTRIX_PLANS: "{{ .Values.available_plans }}" + AVAILABLE_PLANS: {{ .Values.available_plans | toJson }} --- apiVersion: v1 diff --git a/chart/values.yaml b/chart/values.yaml index 59612505ff..0502fe45f1 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -492,7 +492,6 @@ ingress: # alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS": 443}]' # alb.ingress.kubernetes.io/certificate-arn: "certificate-arn" - ingress_class: nginx # Optional: Front-end injected script diff --git a/frontend/src/features/admin/org-quota-editor.ts b/frontend/src/features/admin/org-quota-editor.ts index 8eb994edd4..1727cd6889 100644 --- a/frontend/src/features/admin/org-quota-editor.ts +++ b/frontend/src/features/admin/org-quota-editor.ts @@ -3,9 +3,11 @@ import { type SlDialog } from "@shoelace-style/shoelace"; import { html, type PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { createRef, ref, type Ref } from "lit/directives/ref.js"; +import { until } from "lit/directives/until.js"; import { when } from "lit/directives/when.js"; import { isEqual } from "lodash"; import { type Entries } from "type-fest"; +import z from "zod"; import { BtrixElement } from "@/classes/BtrixElement"; import { type RowEditEventDetail } from "@/components/ui/data-grid/data-grid-row"; @@ -13,83 +15,21 @@ import { GridColumnType, type GridColumn, } from "@/components/ui/data-grid/types"; -import { type OrgData, type OrgQuotas } from "@/utils/orgs"; +import { orgQuotasSchema, type OrgData, type OrgQuotas } from "@/utils/orgs"; import { pluralOf } from "@/utils/pluralize"; -// These were manually copied over from Cashew on 2025-09-15 — please update if necessary -const PRESETS = { - Starter: { - quotas: { - maxConcurrentCrawls: 1, - maxPagesPerCrawl: 2000, - storageQuota: 100_000_000_000, - maxExecMinutesPerMonth: 180, - }, - subscriptionIds: ["starter", "starterTest"], - }, - Standard: { - quotas: { - maxConcurrentCrawls: 2, - maxPagesPerCrawl: 5000, - storageQuota: 220_000_000_000, - maxExecMinutesPerMonth: 360, - }, - subscriptionIds: ["standard", "standardTest"], - }, - Plus: { - quotas: { - maxConcurrentCrawls: 3, - maxPagesPerCrawl: 10000, - storageQuota: 500_000_000_000, - maxExecMinutesPerMonth: 720, - }, - subscriptionIds: ["plus", "plusTest"], - }, - "Pro Standard": { - quotas: { - maxConcurrentCrawls: 4, - maxPagesPerCrawl: 50_000, - storageQuota: 1_000_000_000_000, - maxExecMinutesPerMonth: 50 * 60, - }, - subscriptionIds: ["pro-standard-monthly", "pro-standard-yearly"], - }, - "Pro Teams": { - quotas: { - maxConcurrentCrawls: 5, - maxPagesPerCrawl: 100_000, - storageQuota: 3_000_000_000_000, - maxExecMinutesPerMonth: 80 * 60, - }, - subscriptionIds: ["pro-teams-monthly", "pro-teams-yearly"], - }, - "Pro Plus": { - quotas: { - maxConcurrentCrawls: 10, - maxPagesPerCrawl: 400_000, - storageQuota: 5_000_000_000_000, - maxExecMinutesPerMonth: 150 * 60, - }, - subscriptionIds: ["pro-plus-monthly", "pro-plus-yearly"], - }, - Unset: { - quotas: { - maxConcurrentCrawls: 0, - maxPagesPerCrawl: 0, - storageQuota: 0, - maxExecMinutesPerMonth: 0, - }, - subscriptionIds: [], - }, -} as const satisfies Record< - string, - { - quotas: { - [key in keyof OrgQuotas]?: number; - }; - subscriptionIds?: string[]; - } ->; +const PlanSchema = z.object({ + id: z.string(), + name: z.string(), + org_quotas: orgQuotasSchema, + testmode: z.boolean(), +}); + +const PlansResponseSchema = z.object({ + plans: z.array(PlanSchema), +}); + +type PlansResponse = z.infer; const LABELS = { maxConcurrentCrawls: { @@ -136,6 +76,9 @@ export class OrgQuotaEditor extends BtrixElement { dialog: Ref = createRef(); + @state() + plans = this.api.fetch("/orgs/plans"); + show() { void this.dialog.value?.show(); } @@ -243,65 +186,75 @@ export class OrgQuotaEditor extends BtrixElement { > - ${(Object.entries(PRESETS) as Entries).map( - ([key, value]) => { - const isCurrentSubscription = ( - value.subscriptionIds as string[] - ).includes(this.activeOrg?.subscription?.planId ?? ""); - return html` - { - const newQuota: Partial = {}; - ( - Object.entries(value.quotas) as Entries< - typeof value.quotas - > - ).forEach(([k, v]) => { - newQuota[k] = v - quotas[k]; - }); - this.orgQuotaAdjustments = { ...newQuota }; - }} - > - ${key} - ${isCurrentSubscription - ? html`` - : null} - -
-
- ${key}${isCurrentSubscription - ? html` - - ${msg("This is the current subscription.")}` + ${until( + this.plans.then(({ plans }) => + plans.map(({ id, name, org_quotas }) => { + const isCurrentSubscription = + id === this.activeOrg?.subscription?.planId; + const presets: Omit< + OrgQuotas, + `${"extra" | "gifted"}ExecMinutes` + > = { + maxConcurrentCrawls: org_quotas.maxConcurrentCrawls, + maxExecMinutesPerMonth: org_quotas.maxExecMinutesPerMonth, + maxPagesPerCrawl: org_quotas.maxPagesPerCrawl, + storageQuota: org_quotas.storageQuota, + }; + return html` + { + const newQuota: Partial = {}; + + ( + Object.entries(presets) as Entries + ).forEach(([k, v]) => { + newQuota[k] = v - quotas[k]; + }); + this.orgQuotaAdjustments = { ...newQuota }; + }} + > + ${name} + ${isCurrentSubscription + ? html`` : null} -
+ +
+
+ ${name}${isCurrentSubscription + ? html` - + ${msg( + "This is the current subscription.", + )}` + : null} +
-
- - - ${( - Object.entries(value.quotas) as Entries< - typeof value.quotas - > - ).map( - ([key, value]) => html` - - - - - `, - )} - -
${LABELS[key].label} - ${this.format(value, LABELS[key].type)} -
-
- `; - }, +
+ + + ${( + Object.entries(presets) as Entries + ).map( + ([key, value]) => html` + + + + + `, + )} + +
${LABELS[key].label} + ${this.format(value, LABELS[key].type)} +
+
+
`; + }), + ), + msg("Loading plans..."), )}
From da2fa8f1b6f759702cbf1369ef44619ff7325983 Mon Sep 17 00:00:00 2001 From: emma Date: Tue, 23 Sep 2025 18:27:32 -0400 Subject: [PATCH 09/17] add quota adjustment editing and direct value editing updates the org quota editor to allow both adjustment values and direct value editing for quotas other than additional/gifted execution minutes --- .../components/ui/data-grid/data-grid-cell.ts | 2 +- .../components/ui/data-grid/data-grid-row.ts | 9 +- frontend/src/components/ui/data-grid/types.ts | 2 +- .../src/features/admin/org-quota-editor.ts | 124 +++++++++++++----- 4 files changed, 101 insertions(+), 36 deletions(-) diff --git a/frontend/src/components/ui/data-grid/data-grid-cell.ts b/frontend/src/components/ui/data-grid/data-grid-cell.ts index e45ead6bbf..0bc24ad3a9 100644 --- a/frontend/src/components/ui/data-grid/data-grid-cell.ts +++ b/frontend/src/components/ui/data-grid/data-grid-cell.ts @@ -20,7 +20,7 @@ import { DataGridFocusController } from "@/components/ui/data-grid/controllers/f import type { UrlInput } from "@/components/ui/url-input"; import { tw } from "@/utils/tailwind"; -const cellInputStyle = [ +export const cellInputStyle = [ tw`size-full [--sl-input-background-color-hover:transparent] [--sl-input-background-color:transparent] [--sl-input-border-radius-medium:0] [--sl-input-spacing-medium:var(--sl-spacing-small)] focus:z-10`, // TODO We need to upgrade to Tailwind v4 for inset rings to actually work // tw`focus-within:part-[base]:inset-ring-2`, diff --git a/frontend/src/components/ui/data-grid/data-grid-row.ts b/frontend/src/components/ui/data-grid/data-grid-row.ts index e1e0dd9882..79ba58b8ee 100644 --- a/frontend/src/components/ui/data-grid/data-grid-row.ts +++ b/frontend/src/components/ui/data-grid/data-grid-row.ts @@ -30,8 +30,8 @@ export type RowEditEventDetail = const cell = directive(CellDirective); -const cellStyle = tw`focus-visible:-outline-offset-2`; -const editableCellStyle = tw`p-0 focus-visible:bg-slate-50 `; +const cellStyle = tw`min-w-0 focus-visible:-outline-offset-2`; +const editableCellStyle = tw`min-w-0 p-0 focus-visible:bg-slate-50`; /** * @fires btrix-remove CustomEvent @@ -223,7 +223,10 @@ export class DataGridRow< if (!item) return; - const editable = this.editCells && col.editable; + const editable = + this.editCells && typeof col.editable === "function" + ? col.editable(item) + : col.editable; const tooltipContent = editable ? this.#invalidInputsMap.get(col.field) : col.renderCellTooltip diff --git a/frontend/src/components/ui/data-grid/types.ts b/frontend/src/components/ui/data-grid/types.ts index ac87d34482..2b650fe699 100644 --- a/frontend/src/components/ui/data-grid/types.ts +++ b/frontend/src/components/ui/data-grid/types.ts @@ -40,7 +40,7 @@ export type GridColumn< field: Key; label: string | TemplateResult; description?: string; - editable?: boolean; + editable?: boolean | ((item: Item | undefined) => boolean | undefined); required?: boolean; inputPlaceholder?: string; width?: string; diff --git a/frontend/src/features/admin/org-quota-editor.ts b/frontend/src/features/admin/org-quota-editor.ts index 1727cd6889..a601ddab04 100644 --- a/frontend/src/features/admin/org-quota-editor.ts +++ b/frontend/src/features/admin/org-quota-editor.ts @@ -1,5 +1,6 @@ import { localized, msg, str } from "@lit/localize"; import { type SlDialog } from "@shoelace-style/shoelace"; +import clsx from "clsx"; import { html, type PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { createRef, ref, type Ref } from "lit/directives/ref.js"; @@ -10,6 +11,7 @@ import { type Entries } from "type-fest"; import z from "zod"; import { BtrixElement } from "@/classes/BtrixElement"; +import { cellInputStyle } from "@/components/ui/data-grid/data-grid-cell"; import { type RowEditEventDetail } from "@/components/ui/data-grid/data-grid-row"; import { GridColumnType, @@ -31,7 +33,14 @@ const PlansResponseSchema = z.object({ type PlansResponse = z.infer; -const LABELS = { +const LABELS: { + [key in keyof OrgQuotas]: { + label: string; + type: "number" | "bytes"; + scale?: number; + adjustmentOnly?: boolean; + }; +} = { maxConcurrentCrawls: { label: msg("Max Concurrent Crawls"), type: "number", @@ -52,17 +61,13 @@ const LABELS = { extraExecMinutes: { label: msg("Extra Execution Minutes"), type: "number", + adjustmentOnly: true, }, giftedExecMinutes: { label: msg("Gifted Execution Minutes"), type: "number", + adjustmentOnly: true, }, -} as const satisfies { - [key in keyof OrgQuotas]: { - label: string; - type: "number" | "bytes"; - scale?: number; - }; }; @customElement("btrix-org-quota-editor") @@ -111,7 +116,7 @@ export class OrgQuotaEditor extends BtrixElement { const items = entries.map(([key, value]) => { const labelConfig = LABELS[key]; let currentAdjustment = this.orgQuotaAdjustments[key] || 0; - if (labelConfig.type === "bytes") { + if (labelConfig.scale != undefined) { currentAdjustment = Math.floor( currentAdjustment / labelConfig.scale, ); @@ -135,47 +140,95 @@ export class OrgQuotaEditor extends BtrixElement { { label: msg("Value"), field: "current", - editable: false, + editable: (item) => item && !LABELS[item.key].adjustmentOnly, + inputType: GridColumnType.Number, width: "1fr", - renderCell: ({ item }) => { + renderEditCell: ({ item }) => { + const key = item.key; + let value = item.current; + const labelConfig = LABELS[key]; + + if (labelConfig.scale != undefined) { + value = Math.floor(value / labelConfig.scale); + } + return html` + ${labelConfig.type === "bytes" + ? html`GB` + : ""} + `; + }, + }, + { + label: msg("Adjustment"), + field: "adjustment", + editable: true, + width: "2fr", + inputType: GridColumnType.Number, + renderEditCell: ({ item }) => { const key = item.key; - const value = item.current; + let value = this.orgQuotaAdjustments[key] ?? 0; const labelConfig = LABELS[key]; const format = (v: number, isInitialValue = true) => this.format(v, labelConfig.type, isInitialValue); - return this.orgQuotaAdjustments[key] + const displayValue = this.orgQuotaAdjustments[key] ? Math.sign(this.orgQuotaAdjustments[key]) === 1 ? html` - ${format(value + this.orgQuotaAdjustments[key])} + ${format( + (this.org?.quotas[key] || 0) + + this.orgQuotaAdjustments[key], + )} - (${format(value, false)} + (${format(this.org?.quotas[key] || 0, false)} + ${format(this.orgQuotaAdjustments[key])})` : html` - ${format(value + this.orgQuotaAdjustments[key])} + ${format( + (this.org?.quotas[key] || 0) + + this.orgQuotaAdjustments[key], + )} - (${format(value, false)} + (${format(this.org?.quotas[key] || 0, false)} - ${format(-this.orgQuotaAdjustments[key])})` - : format(value); + : null; + if (labelConfig.scale != undefined) { + value = Math.floor(value / labelConfig.scale); + } + return html` + ${labelConfig.type === "bytes" + ? html`GB` + : ""} + + ${displayValue}`; }, }, - { - label: msg("Adjustment"), - field: "adjustment", - editable: true, - width: "1fr", - inputType: GridColumnType.Number, - min: (item) => -1 * (item?.current ?? 0), - step: 1, - }, ]; return html` @@ -267,13 +320,22 @@ export class OrgQuotaEditor extends BtrixElement { const key = event.detail.rowKey as keyof OrgQuotas; let value = Number(event.detail.value); const labelConfig = LABELS[key]; - if (labelConfig.type === "bytes") { + if (labelConfig.scale != undefined) { value = Math.floor(value * labelConfig.scale); } - this.orgQuotaAdjustments = { - ...this.orgQuotaAdjustments, - [key]: value, - }; + if (event.detail.field === "adjustment") { + console.log("adjustment", value); + this.orgQuotaAdjustments = { + ...this.orgQuotaAdjustments, + [key]: value, + }; + } else if (event.detail.field === "current") { + console.log("current", value); + this.orgQuotaAdjustments = { + ...this.orgQuotaAdjustments, + [key]: value - (this.org?.quotas[key] || 0), + }; + } }} > `; From 4362f526bd08bae5f89b89a8da0cc493edb8d3e8 Mon Sep 17 00:00:00 2001 From: emma Date: Tue, 23 Sep 2025 19:29:45 -0400 Subject: [PATCH 10/17] update table layout with more clear controls --- .../src/features/admin/org-quota-editor.ts | 132 +++++++++--------- 1 file changed, 64 insertions(+), 68 deletions(-) diff --git a/frontend/src/features/admin/org-quota-editor.ts b/frontend/src/features/admin/org-quota-editor.ts index a601ddab04..f0e7097c89 100644 --- a/frontend/src/features/admin/org-quota-editor.ts +++ b/frontend/src/features/admin/org-quota-editor.ts @@ -19,6 +19,7 @@ import { } from "@/components/ui/data-grid/types"; import { orgQuotasSchema, type OrgData, type OrgQuotas } from "@/utils/orgs"; import { pluralOf } from "@/utils/pluralize"; +import { tw } from "@/utils/tailwind"; const PlanSchema = z.object({ id: z.string(), @@ -115,7 +116,7 @@ export class OrgQuotaEditor extends BtrixElement { const entries = Object.entries(quotas) as Entries; const items = entries.map(([key, value]) => { const labelConfig = LABELS[key]; - let currentAdjustment = this.orgQuotaAdjustments[key] || 0; + let currentAdjustment = this.orgQuotaAdjustments[key] ?? 0; if (labelConfig.scale != undefined) { currentAdjustment = Math.floor( currentAdjustment / labelConfig.scale, @@ -123,8 +124,9 @@ export class OrgQuotaEditor extends BtrixElement { } return { key: key, - current: value, + initialValue: value, adjustment: currentAdjustment, + currentValue: value + (this.orgQuotaAdjustments[key] ?? 0), }; }); type Item = (typeof items)[number]; @@ -134,99 +136,95 @@ export class OrgQuotaEditor extends BtrixElement { field: "key", editable: false, width: "2fr", - renderCell: ({ item }) => html`${LABELS[item.key].label}`, + renderCell: ({ item }) => + html`${LABELS[item.key].label}`, align: "start", }, { - label: msg("Value"), - field: "current", - editable: (item) => item && !LABELS[item.key].adjustmentOnly, - inputType: GridColumnType.Number, + label: "Initial Value", + field: "initialValue", + editable: false, + width: "1fr", + renderCell: ({ item: { key, initialValue } }) => + html`${this.format(initialValue, LABELS[key].type, true)}`, + }, + { + label: msg("Adjustment"), + field: "adjustment", + editable: true, width: "1fr", + inputType: GridColumnType.Number, renderEditCell: ({ item }) => { const key = item.key; - let value = item.current; + let value = this.orgQuotaAdjustments[key] ?? 0; const labelConfig = LABELS[key]; if (labelConfig.scale != undefined) { value = Math.floor(value / labelConfig.scale); } return html` 0 + ? tw`text-green-600 part-[input]:text-green-600` + : tw`text-red-600 part-[input]:text-red-600`), + )} type="number" value="${value}" - min="0" + min=${-1 * item.initialValue} step="1" > + ${value > 0 + ? html`+` + : null} ${labelConfig.type === "bytes" - ? html`GB` - : ""} + : null} `; }, }, { - label: msg("Adjustment"), - field: "adjustment", - editable: true, - width: "2fr", + label: msg("New Value"), + field: "currentValue", + editable: (item) => item && !LABELS[item.key].adjustmentOnly, inputType: GridColumnType.Number, - renderEditCell: ({ item }) => { + width: "1fr", + renderCell: ({ item: { key, currentValue: current } }) => + html`${this.format(current, LABELS[key].type, true)}`, + renderEditCell: ({ item, value: _value }) => { const key = item.key; - let value = this.orgQuotaAdjustments[key] ?? 0; + let value = _value as number; const labelConfig = LABELS[key]; - const format = (v: number, isInitialValue = true) => - this.format(v, labelConfig.type, isInitialValue); - const displayValue = this.orgQuotaAdjustments[key] - ? Math.sign(this.orgQuotaAdjustments[key]) === 1 - ? html` - ${format( - (this.org?.quotas[key] || 0) + - this.orgQuotaAdjustments[key], - )} - - (${format(this.org?.quotas[key] || 0, false)} - - + ${format(this.orgQuotaAdjustments[key])})` - : html` - ${format( - (this.org?.quotas[key] || 0) + - this.orgQuotaAdjustments[key], - )} - - (${format(this.org?.quotas[key] || 0, false)} - - - ${format(-this.orgQuotaAdjustments[key])})` - : null; if (labelConfig.scale != undefined) { value = Math.floor(value / labelConfig.scale); } return html` - ${labelConfig.type === "bytes" - ? html`GB` - : ""} - - ${displayValue}`; + class=${clsx(cellInputStyle)} + type="number" + value="${value}" + min="0" + step="1" + > + ${labelConfig.type === "bytes" + ? html`GB` + : ""} + `; }, }, ]; @@ -324,16 +322,14 @@ export class OrgQuotaEditor extends BtrixElement { value = Math.floor(value * labelConfig.scale); } if (event.detail.field === "adjustment") { - console.log("adjustment", value); this.orgQuotaAdjustments = { ...this.orgQuotaAdjustments, [key]: value, }; - } else if (event.detail.field === "current") { - console.log("current", value); + } else if (event.detail.field === "currentValue") { this.orgQuotaAdjustments = { ...this.orgQuotaAdjustments, - [key]: value - (this.org?.quotas[key] || 0), + [key]: value - (quotas[key] || 0), }; } }} From 296e0bbd09a77785f8277cecf1a2a5e08c8a87bf Mon Sep 17 00:00:00 2001 From: emma Date: Tue, 23 Sep 2025 19:54:33 -0400 Subject: [PATCH 11/17] add warnings about plan preset mismatches --- .../src/features/admin/org-quota-editor.ts | 69 ++++++++++++++++--- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/frontend/src/features/admin/org-quota-editor.ts b/frontend/src/features/admin/org-quota-editor.ts index f0e7097c89..fe576b2e5f 100644 --- a/frontend/src/features/admin/org-quota-editor.ts +++ b/frontend/src/features/admin/org-quota-editor.ts @@ -147,7 +147,9 @@ export class OrgQuotaEditor extends BtrixElement { width: "1fr", renderCell: ({ item: { key, initialValue } }) => html`${this.format(initialValue, LABELS[key].type, true)}${this.format(initialValue, LABELS[key].type, { + asNumber: true, + })}`, }, { @@ -202,7 +204,9 @@ export class OrgQuotaEditor extends BtrixElement { width: "1fr", renderCell: ({ item: { key, currentValue: current } }) => html`${this.format(current, LABELS[key].type, true)}${this.format(current, LABELS[key].type, { + asNumber: true, + })}`, renderEditCell: ({ item, value: _value }) => { const key = item.key; @@ -251,6 +255,11 @@ export class OrgQuotaEditor extends BtrixElement { maxPagesPerCrawl: org_quotas.maxPagesPerCrawl, storageQuota: org_quotas.storageQuota, }; + const mismatchesCurrentPlan = + isCurrentSubscription && + (Object.entries(presets) as Entries).some( + ([k, v]) => v !== quotas[k], + ); return html` { @@ -271,6 +280,13 @@ export class OrgQuotaEditor extends BtrixElement { slot="prefix" >` : null} + ${mismatchesCurrentPlan + ? html`` + : null}
@@ -289,18 +305,38 @@ export class OrgQuotaEditor extends BtrixElement { ${( Object.entries(presets) as Entries - ).map( - ([key, value]) => html` + ).map(([key, value]) => { + const currentValue = this.format( + quotas[key], + LABELS[key].type, + { plain: true }, + ); + return html` ${LABELS[key].label} ${this.format(value, LABELS[key].type)} + ${mismatchesCurrentPlan && + value !== quotas[key] + ? html`(${msg( + html`currently ${currentValue}`, + )})` + : null} - `, - )} + `; + })} + ${mismatchesCurrentPlan + ? html`

+ ${msg( + "Quotas for this org do not match its current plan.", + )} +

` + : null}
`; }), @@ -353,12 +389,23 @@ export class OrgQuotaEditor extends BtrixElement { `; } - private format(v: number, type: "bytes" | "number", isInitialValue = true) { - if (v <= 0) - return isInitialValue - ? html`${msg("Unset")}` - : html`${msg("0")}`; + private format( + v: number, + type: "bytes" | "number", + options: { plain?: boolean; asNumber?: boolean } = {}, + ) { + const { plain, asNumber } = options; const fn = type === "bytes" ? this.localize.bytes : this.localize.number; + if (plain) { + if (v <= 0) { + return asNumber ? fn(0) : msg("Unset"); + } + return fn(v); + } + if (v <= 0) + return asNumber + ? html`${fn(0)}` + : html`${msg("Unset")}`; return fn(v); } From 5dad4dcd0ea81b2b66773241974d46fa33e612ce Mon Sep 17 00:00:00 2001 From: emma Date: Tue, 23 Sep 2025 19:59:35 -0400 Subject: [PATCH 12/17] add warning specifically about subtractive changes --- frontend/src/features/admin/org-quota-editor.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/admin/org-quota-editor.ts b/frontend/src/features/admin/org-quota-editor.ts index fe576b2e5f..3284eaef49 100644 --- a/frontend/src/features/admin/org-quota-editor.ts +++ b/frontend/src/features/admin/org-quota-editor.ts @@ -104,6 +104,9 @@ export class OrgQuotaEditor extends BtrixElement { const changeCount = Object.values(this.orgQuotaAdjustments).filter( (value) => !!value, ).length; + const subtractiveChanges = Object.values(this.orgQuotaAdjustments).filter( + (value) => value < 0, + ).length; return html`
${this.localize.number(changeCount)} - ${pluralOf("changes", changeCount)} + ${pluralOf("changes", changeCount)}${subtractiveChanges > 0 + ? html`, + ${this.localize.number(subtractiveChanges)} + ${msg("subtractive")}` + : null}
Date: Tue, 23 Sep 2025 20:05:27 -0400 Subject: [PATCH 13/17] format --- backend/btrixcloud/models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/btrixcloud/models.py b/backend/btrixcloud/models.py index c038e2cc99..818f37dc49 100644 --- a/backend/btrixcloud/models.py +++ b/backend/btrixcloud/models.py @@ -1849,6 +1849,7 @@ class OrgQuotasIn(BaseModel): extraExecMinutes: Optional[int] = None giftedExecMinutes: Optional[int] = None + # ============================================================================ class Plan(BaseModel): """Available Browsertrix plan, from env""" @@ -1858,12 +1859,14 @@ class Plan(BaseModel): org_quotas: OrgQuotas testmode: bool = False + # ============================================================================ class PlansResponse(BaseModel): """Response for plans api endpoint""" plans: list[Plan] + # ============================================================================ class SubscriptionEventOut(BaseModel): """Fields to add to output models for subscription events""" From 01e17340f9974139f8e5ad05baa030dfe620903f Mon Sep 17 00:00:00 2001 From: emma Date: Thu, 25 Sep 2025 14:39:51 -0400 Subject: [PATCH 14/17] remove commented-out code --- frontend/src/components/orgs-list.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/frontend/src/components/orgs-list.ts b/frontend/src/components/orgs-list.ts index a5a57e0b50..b36b8690cf 100644 --- a/frontend/src/components/orgs-list.ts +++ b/frontend/src/components/orgs-list.ts @@ -660,16 +660,6 @@ export class OrgsList extends BtrixElement { } } - // private onSubmitQuotas() { - // if (this.currOrg) { - // this.dispatchEvent( - // new CustomEvent("update-quotas", { detail: this.currOrg }), - // ); - - // void this.orgQuotaDialog?.hide(); - // } - // } - private onSubmitProxies() { if (this.currOrg) { this.dispatchEvent( From 0e9edc241e31d6eb1e0ebbc605e2d09264899333 Mon Sep 17 00:00:00 2001 From: emma Date: Thu, 25 Sep 2025 14:53:15 -0400 Subject: [PATCH 15/17] add "unset" preset if no plans are available from backend --- .../src/features/admin/org-quota-editor.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/admin/org-quota-editor.ts b/frontend/src/features/admin/org-quota-editor.ts index 3284eaef49..e6016d9467 100644 --- a/frontend/src/features/admin/org-quota-editor.ts +++ b/frontend/src/features/admin/org-quota-editor.ts @@ -71,6 +71,24 @@ const LABELS: { }, }; +const defaultPlans: PlansResponse = { + plans: [ + { + id: "unset", + name: "Unset", + org_quotas: { + extraExecMinutes: 0, + giftedExecMinutes: 0, + maxConcurrentCrawls: 0, + maxExecMinutesPerMonth: 0, + maxPagesPerCrawl: 0, + storageQuota: 0, + }, + testmode: false, + }, + ], +}; + @customElement("btrix-org-quota-editor") @localized() export class OrgQuotaEditor extends BtrixElement { @@ -83,7 +101,10 @@ export class OrgQuotaEditor extends BtrixElement { dialog: Ref = createRef(); @state() - plans = this.api.fetch("/orgs/plans"); + plans = this.api + .fetch("/orgs/plans") + // Default to an "unset" plan preset if no plans are available from the backend + .then((plans) => (plans.plans.length === 0 ? defaultPlans : plans)); show() { void this.dialog.value?.show(); From d7e29e641983b86639ee427cb01e4173bbfdd07e Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Thu, 25 Sep 2025 12:59:15 -0700 Subject: [PATCH 16/17] Apply suggestion from @ikreymer --- backend/btrixcloud/orgs.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/btrixcloud/orgs.py b/backend/btrixcloud/orgs.py index bed53bc3d8..08981ba730 100644 --- a/backend/btrixcloud/orgs.py +++ b/backend/btrixcloud/orgs.py @@ -1622,7 +1622,6 @@ async def get_plans(user: User = Depends(user_dep)): if not user.is_superuser: raise HTTPException(status_code=403, detail="Not Allowed") plans_json = os.environ.get("AVAILABLE_PLANS") - print("DEBUG plans_json:", plans_json) if not plans_json: print("Info: Plans not configured") return PlansResponse(plans=[]) From 65b936cac6fe58d706196d2d0c68eec156f0dab1 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Thu, 25 Sep 2025 13:00:43 -0700 Subject: [PATCH 17/17] Apply suggestion from @ikreymer --- backend/btrixcloud/orgs.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/btrixcloud/orgs.py b/backend/btrixcloud/orgs.py index 08981ba730..9a60f90003 100644 --- a/backend/btrixcloud/orgs.py +++ b/backend/btrixcloud/orgs.py @@ -1623,7 +1623,6 @@ async def get_plans(user: User = Depends(user_dep)): raise HTTPException(status_code=403, detail="Not Allowed") plans_json = os.environ.get("AVAILABLE_PLANS") if not plans_json: - print("Info: Plans not configured") return PlansResponse(plans=[]) try: plans = PlansResponse.model_validate_json(plans_json)