diff --git a/frontend/src/components/ui/floating-popover.ts b/frontend/src/components/ui/floating-popover.ts new file mode 100644 index 0000000000..318f303c59 --- /dev/null +++ b/frontend/src/components/ui/floating-popover.ts @@ -0,0 +1,244 @@ +import { type VirtualElement } from "@shoelace-style/shoelace/dist/components/popup/popup.component.js"; +import SlTooltip from "@shoelace-style/shoelace/dist/components/tooltip/tooltip.component.js"; +import slTooltipStyles from "@shoelace-style/shoelace/dist/components/tooltip/tooltip.styles.js"; +import { css, html, type PropertyValues } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; + +/** Re-implemented from Shoelace, since it's not exported */ +function parseDuration(delay: number | string) { + delay = delay.toString().toLowerCase(); + + if (delay.indexOf("ms") > -1) { + return parseFloat(delay); + } + + if (delay.indexOf("s") > -1) { + return parseFloat(delay) * 1000; + } + + return parseFloat(delay); +} + +/** + * Floating popovers are used to show labels and additional details in data visualizations. + * They're hidden until hover, and follow the cursor within the anchor element. + * + * Importantly, they are not interactive and do not respond to user input via keyboard. + * Their content will not be accessible to screen readers or other assistive technologies. + * + * @attr {String} content + * @attr {String} placement + * @attr {String} distance + * @attr {String} trigger + * @attr {Boolean} open + * @attr {Boolean} disabled + */ +@customElement("btrix-floating-popover") +export class FloatingPopover extends SlTooltip { + @property({ type: Boolean, reflect: true }) + hoist = true; + + @property({ type: String, reflect: true }) + placement: SlTooltip["placement"] = "bottom"; + + @property({ type: String, reflect: true }) + lock: "x" | "y" | "x y" | "" = "y"; + + clientX: number | null = 0; + clientY: number | null = 0; + + isHovered = false; + + private get slottedChildren() { + const slot = this.shadowRoot!.querySelector("slot"); + return slot?.assignedElements({ flatten: true }); + } + + get anchor(): VirtualElement { + let originalRect: DOMRect | undefined; + if (this.lock !== "") { + originalRect = this.slottedChildren?.[0].getBoundingClientRect(); + } + return { + getBoundingClientRect: () => { + return new DOMRect( + (this.hasLock("x") ? originalRect?.x : this.clientX) ?? 0, + (this.hasLock("y") ? originalRect?.y : this.clientY) ?? 0, + this.hasLock("x") ? originalRect?.width : 0, + this.hasLock("y") ? originalRect?.height : 0, + ); + }, + }; + } + + static styles = [ + slTooltipStyles, + css` + :host { + --btrix-border: 1px solid var(--sl-color-neutral-300); + --sl-tooltip-border-radius: var(--sl-border-radius-large); + --sl-tooltip-background-color: var(--sl-color-neutral-50); + --sl-tooltip-color: var(--sl-color-neutral-700); + --sl-tooltip-font-size: var(--sl-font-size-x-small); + --sl-tooltip-padding: var(--sl-spacing-small); + --sl-tooltip-line-height: var(--sl-line-height-dense); + } + + .tooltip__body { + border: var(--btrix-border); + box-shadow: var(--sl-shadow-small), var(--sl-shadow-large); + } + + ::part(popup) { + pointer-events: none; + } + + ::part(arrow) { + z-index: 1; + } + + [data-current-placement^="bottom"]::part(arrow), + [data-current-placement^="left"]::part(arrow) { + border-top: var(--btrix-border); + } + + [data-current-placement^="bottom"]::part(arrow), + [data-current-placement^="right"]::part(arrow) { + border-left: var(--btrix-border); + } + + [data-current-placement^="top"]::part(arrow), + [data-current-placement^="right"]::part(arrow) { + border-bottom: var(--btrix-border); + } + + [data-current-placement^="top"]::part(arrow), + [data-current-placement^="left"]::part(arrow) { + border-right: var(--btrix-border); + } + `, + ]; + + constructor() { + super(); + this.addEventListener("mouseover", this.overrideHandleMouseOver); + this.addEventListener("mouseout", this.overrideHandleMouseOut); + } + + override render() { + return html` + + + + + + `; + } + + connectedCallback(): void { + super.connectedCallback(); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + document.body.removeEventListener("mousemove", this.handleMouseMove); + } + + async handleOptionsChange() { + if (this.hasUpdated) { + await this.updateComplete; + this.popup.reposition(); + } + } + + hasChanged(changedProps: PropertyValues) { + if ( + ( + [ + "content", + "distance", + "hoist", + "placement", + "skidding", + ] as (keyof FloatingPopover)[] + ).some(changedProps.has) + ) { + void this.handleOptionsChange(); + } + } + + handleMouseMove = (event: MouseEvent) => { + if (this.isHovered) { + this.clientX = event.clientX; + this.clientY = event.clientY; + this.popup.reposition(); + } + }; + + private readonly overrideHandleMouseOver = (event: MouseEvent) => { + if (this.overrideHasTrigger("hover")) { + this.isHovered = true; + this.clientX = event.clientX; + this.clientY = event.clientY; + document.body.addEventListener("mousemove", this.handleMouseMove); + const delay = parseDuration( + getComputedStyle(this).getPropertyValue("--show-delay"), + ); + // @ts-expect-error need to access SlTooltip's hoverTimeout + clearTimeout(this.hoverTimeout as number | undefined); + // @ts-expect-error need to access SlTooltip's hoverTimeout + this.hoverTimeout = window.setTimeout(async () => this.show(), delay); + } + }; + + private readonly overrideHandleMouseOut = () => { + if (this.overrideHasTrigger("hover")) { + this.isHovered = false; + document.body.removeEventListener("mousemove", this.handleMouseMove); + const delay = parseDuration( + getComputedStyle(this).getPropertyValue("--hide-delay"), + ); + // @ts-expect-error need to access SlTooltip's hoverTimeout + clearTimeout(this.hoverTimeout as number | undefined); + // @ts-expect-error need to access SlTooltip's hoverTimeout + this.hoverTimeout = window.setTimeout(async () => this.hide(), delay); + } + }; + + private readonly overrideHasTrigger = (triggerType: string) => { + const triggers = this.trigger.split(" "); + return triggers.includes(triggerType); + }; + + private readonly hasLock = (lockType: "x" | "y") => { + const locks = this.lock.split(" "); + return locks.includes(lockType); + }; +} diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index 632598bc7c..10fda37edd 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -22,6 +22,7 @@ import("./details"); import("./file-input"); import("./file-list"); import("./filter-chip"); +import("./floating-popover"); import("./format-date"); import("./inline-input"); import("./language-select"); diff --git a/frontend/src/components/ui/meter.ts b/frontend/src/components/ui/meter.ts index b9667755e6..d616ef4f85 100644 --- a/frontend/src/components/ui/meter.ts +++ b/frontend/src/components/ui/meter.ts @@ -5,9 +5,7 @@ import { query, queryAssignedElements, } from "lit/decorators.js"; -import { classMap } from "lit/directives/class-map.js"; import { ifDefined } from "lit/directives/if-defined.js"; -import { when } from "lit/directives/when.js"; import debounce from "lodash/fp/debounce"; import { TailwindElement } from "@/classes/TailwindElement"; @@ -20,78 +18,45 @@ export class MeterBar extends TailwindElement { @property({ type: Number }) value = 0; - // postcss-lit-disable-next-line - static styles = css` - :host { - display: contents; - } - - .bar { - height: 1rem; - background-color: var(--background-color, var(--sl-color-blue-500)); - min-width: 4px; - transition: 400ms width; - } - `; + @property({ type: String }) + placement: "top" | "bottom" = "top"; - render() { - if (this.value <= 0) { - return; + updated(changedProperties: PropertyValues) { + if (changedProperties.has("value")) { + this.style.width = `${this.value}%`; + if (this.value <= 0) { + this.style.display = "none"; + } else { + this.style.display = ""; + } } - return html` -
-
-
`; } -} - -@customElement("btrix-divided-meter-bar") -export class DividedMeterBar extends TailwindElement { - /* Percentage of value / max */ - @property({ type: Number }) - value = 0; - - @property({ type: Number }) - quota = 0; + // postcss-lit-disable-next-line static styles = css` :host { - display: contents; - } - - .bar { - height: 1rem; - background-color: var(--background-color, var(--sl-color-blue-400)); + display: block; + --background-color: var(--background-color, var(--sl-color-blue-500)); + overflow: hidden; + transition: box-shadow 150ms cubic-bezier(0.4, 0, 0.2, 1); min-width: 4px; + transition: 400ms width; } - .rightBorderRadius { - border-radius: 0 var(--sl-border-radius-medium) - var(--sl-border-radius-medium) 0; - } - - .quotaBar { + .bar { height: 1rem; - background-color: var(--quota-background-color, var(--sl-color-blue-100)); - min-width: 4px; - box-shadow: inset 0px 1px 1px 0px rgba(0, 0, 0, 0.25); + background-color: var(--background-color); } `; render() { - return html` + if (this.value <= 0) { + return; + } + return html`
-
- ${when(this.value, () => { - return html`
`; - })} -
-
`; +
+ `; } } @@ -117,6 +82,9 @@ export class Meter extends TailwindElement { @property({ type: String }) valueText?: string; + @property({ type: Boolean }) + hasBackground = false; + @query(".labels") private readonly labels?: HTMLElement; @@ -145,14 +113,56 @@ export class Meter extends TailwindElement { height: 1rem; border-radius: var(--sl-border-radius-medium); background-color: var(--sl-color-neutral-100); - box-shadow: inset 0px 1px 1px 0px rgba(0, 0, 0, 0.25); + box-shadow: inset 0 0 0 1px var(--sl-color-neutral-300); + position: relative; } .valueBar { + box-shadow: var(--sl-shadow-medium); + } + + .valueBar:after, + .track:after { + content: ""; + position: absolute; + top: 100%; + right: 0; + width: 1px; + height: 6px; + background-color: var(--sl-color-neutral-400); + pointer-events: none; + z-index: -1; + } + + .valueBar[data-empty]::after { + right: unset; + left: 0; + } + + .valueBar, + .background { display: flex; border-radius: var(--sl-border-radius-medium); - overflow: hidden; transition: 400ms width; + position: relative; + } + + .valueBar::before, + .background::before { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: var(--sl-border-radius-medium); + content: ""; + box-shadow: inset 0 0 0 1px var(--sl-color-neutral-500); + mix-blend-mode: color-burn; + pointer-events: none; + } + + .valueBar::before { + z-index: 1; } .labels { @@ -161,8 +171,6 @@ export class Meter extends TailwindElement { white-space: nowrap; color: var(--sl-color-neutral-500); font-size: var(--sl-font-size-x-small); - font-family: var(--font-monostyle-family); - font-variation-settings: var(--font-monostyle-variation); line-height: 1; margin-top: var(--sl-spacing-x-small); } @@ -183,6 +191,70 @@ export class Meter extends TailwindElement { .maxText { display: inline-flex; } + + .valueBar ::slotted(btrix-meter-bar) { + position: relative; + transition-property: box-shadow, opacity; + transition-duration: 150ms; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + border-radius: 1px; + --darkened-background-color-1: oklch( + from var(--background-color) calc(l - 0.2) c h + ); + --darkened-background-color-2: oklch( + from var(--background-color) calc(l - 0.1) calc(c + 0.1) h / 0.5 + ); + } + + .valueBar ::slotted(btrix-meter-bar):after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: var(--sl-color-neutral-100); + opacity: 0; + transition-property: opacity; + transition-duration: 150ms; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: none; + } + + .valueBar:hover ::slotted(btrix-meter-bar:not(:hover)):after { + opacity: 0.5; + } + + .valueBar:hover ::slotted(btrix-meter-bar:hover) { + box-shadow: + 0 0 0 1px var(--darkened-background-color-1), + 0 1px 3px 0 var(--darkened-background-color-2), + 0 1px 2px -1px var(--darkened-background-color-2); + z-index: 1; + } + + .background { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 1rem; + border-radius: var(--sl-border-radius-medium); + overflow: hidden; + } + + .valueBar ::slotted(btrix-meter-bar:first-of-type), + .valueBar ::slotted(btrix-meter-bar:first-of-type):after, + .valueBar:hover ::slotted(btrix-meter-bar:first-of-type) { + border-top-left-radius: var(--sl-border-radius-medium); + border-bottom-left-radius: var(--sl-border-radius-medium); + } + .valueBar ::slotted(btrix-meter-bar:last-of-type), + .valueBar ::slotted(btrix-meter-bar:last-of-type):after, + .valueBar:hover ::slotted(btrix-meter-bar:last-of-type) { + border-top-right-radius: var(--sl-border-radius-medium); + border-bottom-right-radius: var(--sl-border-radius-medium); + } `; @queryAssignedElements({ selector: "btrix-meter-bar" }) @@ -224,7 +296,16 @@ export class Meter extends TailwindElement { >} >
-
+ ${this.hasBackground + ? html`
+ +
` + : null} +
${this.value < max ? html`` : ""} diff --git a/frontend/src/features/archived-items/archived-item-state-filter.ts b/frontend/src/features/archived-items/archived-item-state-filter.ts index 541db1e4b7..ce0642c4cd 100644 --- a/frontend/src/features/archived-items/archived-item-state-filter.ts +++ b/frontend/src/features/archived-items/archived-item-state-filter.ts @@ -107,7 +107,7 @@ export class ArchivedItemStateFilter extends BtrixElement {
; diff --git a/frontend/src/features/meters/execution-minutes/execution-minute-meter.ts b/frontend/src/features/meters/execution-minutes/execution-minute-meter.ts new file mode 100644 index 0000000000..85be7f0ea3 --- /dev/null +++ b/frontend/src/features/meters/execution-minutes/execution-minute-meter.ts @@ -0,0 +1,250 @@ +import { localized, msg } from "@lit/localize"; +import { html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { when } from "lit/directives/when.js"; + +import { executionMinuteColors } from "./colors"; +import { renderBar, type RenderBarProps } from "./render-bar"; +import { tooltipRow } from "./tooltip"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import { renderLegendColor } from "@/features/meters/utils/legend"; +import { type Metrics } from "@/types/org"; +import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter"; + +export type Bucket = "monthly" | "gifted" | "extra"; + +const EXEC_MINUTE_ORDER = [ + "monthly", + "gifted", + "extra", +] as const satisfies Bucket[]; + +@customElement("btrix-execution-minute-meter") +@localized() +export class ExecutionMinuteMeter extends BtrixElement { + @property({ type: Object }) + metrics?: Metrics; + + render() { + if (!this.metrics) return; + return this.renderExecutionMinuteMeter2(); + } + + private readonly renderExecutionMinuteMeter2 = () => { + if (!this.org) return; + + const now = new Date(); + const currentYear = now.getFullYear(); + const currentMonth = String(now.getUTCMonth() + 1).padStart(2, "0"); + const currentPeriod = `${currentYear}-${currentMonth}`; + + /** Usages in seconds */ + const usage = { + monthly: this.org.monthlyExecSeconds?.[currentPeriod] ?? 0, + extra: this.org.extraExecSeconds?.[currentPeriod] ?? 0, + gifted: this.org.giftedExecSeconds?.[currentPeriod] ?? 0, + total: + (this.org.monthlyExecSeconds?.[currentPeriod] ?? 0) + + (this.org.extraExecSeconds?.[currentPeriod] ?? 0) + + (this.org.giftedExecSeconds?.[currentPeriod] ?? 0), + }; + + /** Quotas in seconds */ + const quotas = { + monthly: this.org.quotas.maxExecMinutesPerMonth * 60, + extra: this.org.extraExecSecondsAvailable + usage.extra, + gifted: this.org.giftedExecSecondsAvailable + usage.gifted, + total: + this.org.quotas.maxExecMinutesPerMonth * 60 + + this.org.extraExecSecondsAvailable + + usage.extra + + this.org.giftedExecSecondsAvailable + + usage.gifted, + }; + + if (Math.abs(quotas.extra - this.org.quotas.extraExecMinutes * 60) > 0) { + console.debug("WARN extra minutes doesn't match quotas", { + quota: quotas.extra, + usage: usage.extra, + available: this.org.extraExecSecondsAvailable, + expected: this.org.quotas.extraExecMinutes * 60, + }); + } + + if (Math.abs(quotas.gifted - this.org.quotas.giftedExecMinutes * 60) > 0) { + console.debug("WARN gifted minutes doesn't match quotas", { + quota: quotas.gifted, + usage: usage.gifted, + available: this.org.giftedExecSecondsAvailable, + expected: this.org.quotas.giftedExecMinutes * 60, + }); + } + + /** Width values in reference to the total width of the value bar (usage.total) */ + const usedValues = { + monthly: usage.total === 0 ? 0 : usage.monthly / usage.total, + extra: usage.total === 0 ? 0 : usage.extra / usage.total, + gifted: usage.total === 0 ? 0 : usage.gifted / usage.total, + }; + + /** Width values in reference to the total width of the meter (quotas.total) */ + const backgroundValues = { + monthly: (quotas.monthly - usage.monthly) / quotas.total, + extra: (quotas.extra - usage.extra) / quotas.total, + gifted: (quotas.gifted - usage.gifted) / quotas.total, + total: usage.total / quotas.total, + }; + + const hasQuota = quotas.monthly > 0; + const isReached = hasQuota && usage.total >= quotas.total; + + const foregroundTooltipContent = (currentBucket: Bucket) => { + const rows = EXEC_MINUTE_ORDER.filter((bucket) => usedValues[bucket] > 0); + if (rows.length < 2) return; + return html`
+ ${rows.map((bucket) => + tooltipRow( + { + monthly: msg("Monthly"), + extra: msg("Extra"), + gifted: msg("Gifted"), + }[bucket], + usage[bucket], + bucket === currentBucket, + executionMinuteColors[bucket].foreground, + ), + )} +
+ ${tooltipRow(msg("All used execution time"), usage.total)}`; + }; + + const backgroundTooltipContent = (currentBucket: Bucket) => { + const rows = EXEC_MINUTE_ORDER.filter( + (bucket) => backgroundValues[bucket] > 0, + ); + if (rows.length < 2) return; + return html`
+ ${rows.map((bucket) => + tooltipRow( + { + monthly: msg("Monthly Remaining"), + extra: msg("Extra Remaining"), + gifted: msg("Gifted Remaining"), + }[bucket], + quotas[bucket] - usage[bucket], + bucket === currentBucket, + executionMinuteColors[bucket].background, + ), + )} +
+ ${tooltipRow( + msg("All remaining execution time"), + quotas.total - usage.total, + )}`; + }; + + const foregroundBarConfig = (bucket: Bucket): RenderBarProps => ({ + value: usedValues[bucket], + usedSeconds: Math.min(usage[bucket], quotas[bucket]), + quotaSeconds: quotas[bucket], + totalQuotaSeconds: quotas.total, + title: html`${renderLegendColor( + executionMinuteColors[bucket].foreground, + )}${{ + monthly: msg("Used Monthly Execution Time"), + extra: msg("Used Extra Execution Time"), + gifted: msg("Used Gifted Execution Time"), + }[bucket]}`, + color: executionMinuteColors[bucket].foreground.primary, + highlight: "used", + content: foregroundTooltipContent(bucket), + }); + + const firstBackgroundBar = + EXEC_MINUTE_ORDER.find((group) => backgroundValues[group] !== 0) ?? + "monthly"; + + const backgroundBarConfig = (bucket: Bucket): RenderBarProps => ({ + value: + backgroundValues[bucket] + + // If the bucket is the first background bar, extend it to the width of the value bar + // plus its own value, so that it extends under the value bar's rounded corners + (bucket === firstBackgroundBar ? backgroundValues.total : 0), + title: html`${renderLegendColor( + executionMinuteColors[bucket].background, + )}${{ + monthly: msg("Remaining Monthly Execution Time"), + extra: msg("Remaining Extra Execution Time"), + gifted: msg("Remaining Gifted Execution Time"), + }[bucket]}`, + highlight: "available", + content: backgroundTooltipContent(bucket), + usedSeconds: Math.max(usage[bucket], quotas[bucket]), + quotaSeconds: quotas[bucket], + availableSeconds: Math.max(0, quotas[bucket] - usage[bucket]), + totalQuotaSeconds: Math.max(0, quotas.total - usage.total), + color: executionMinuteColors[bucket].background.primary, + }); + + return html` +
+ ${when( + isReached, + () => html` +
+ + ${msg("Execution Minutes Quota Reached")} +
+ `, + () => + hasQuota && this.org + ? html` + + ${humanizeExecutionSeconds(quotas.total - usage.total, { + style: "short", + round: "down", + })} + ${msg("remaining")} + + ` + : "", + )} +
+ ${when( + hasQuota && this.org, + () => html` +
+ + ${EXEC_MINUTE_ORDER.map((bucket) => + renderBar(foregroundBarConfig(bucket)), + )} + +
+ ${EXEC_MINUTE_ORDER.map((bucket) => + renderBar(backgroundBarConfig(bucket)), + )} +
+ + + ${humanizeExecutionSeconds(usage.total, { + style: "short", + })} + + + ${humanizeExecutionSeconds(quotas.total, { + style: "short", + })} + +
+
+ `, + )} + `; + }; +} diff --git a/frontend/src/features/meters/execution-minutes/render-bar.ts b/frontend/src/features/meters/execution-minutes/render-bar.ts new file mode 100644 index 0000000000..76f6caa2d3 --- /dev/null +++ b/frontend/src/features/meters/execution-minutes/render-bar.ts @@ -0,0 +1,52 @@ +import { html, type TemplateResult } from "lit"; + +import { tooltipContent } from "@/features/meters/utils/tooltip"; +import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter"; + +export type RenderBarProps = { + value: number; + usedSeconds: number; + quotaSeconds: number; + totalQuotaSeconds?: number; + title: string | TemplateResult; + content?: string | TemplateResult; + color: string; + highlight?: "used" | "available" | "totalAvailable"; + availableSeconds?: number; +}; + +export const renderBar = ({ + value, + usedSeconds, + quotaSeconds, + availableSeconds, + totalQuotaSeconds = quotaSeconds, + title, + content, + color, + highlight = "used", +}: RenderBarProps) => { + if (value === 0) return; + availableSeconds ??= quotaSeconds; + return html` + ${tooltipContent({ + title, + value: humanizeExecutionSeconds( + { + used: usedSeconds, + available: availableSeconds, + totalAvailable: totalQuotaSeconds, + }[highlight], + { + displaySeconds: true, + round: highlight === "used" ? "up" : "down", + }, + ), + content, + })} + `; +}; diff --git a/frontend/src/features/meters/execution-minutes/tooltip.ts b/frontend/src/features/meters/execution-minutes/tooltip.ts new file mode 100644 index 0000000000..20ad2a1e9e --- /dev/null +++ b/frontend/src/features/meters/execution-minutes/tooltip.ts @@ -0,0 +1,32 @@ +import clsx from "clsx"; +import { html } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { renderLegendColor } from "@/features/meters/utils/legend"; +import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter"; +import { tw } from "@/utils/tailwind"; + +export const tooltipRow = ( + title: string, + value: number, + highlight = false, + color?: { primary: string; border: string }, +) => html` +

+ ${color ? renderLegendColor(color) : null}${title} + ${humanizeExecutionSeconds(value, { + round: "down", + displaySeconds: true, + })} +

+`; diff --git a/frontend/src/features/meters/has-quotas.ts b/frontend/src/features/meters/has-quotas.ts new file mode 100644 index 0000000000..cef5ed71cf --- /dev/null +++ b/frontend/src/features/meters/has-quotas.ts @@ -0,0 +1,67 @@ +import { type OrgData } from "@/types/org"; + +export function hasExecutionMinuteQuota(org: OrgData | null | undefined) { + if (!org) return; + + let quotaSeconds = 0; + + if (org.quotas.maxExecMinutesPerMonth) { + quotaSeconds = org.quotas.maxExecMinutesPerMonth * 60; + } + + let quotaSecondsAllTypes = quotaSeconds; + + if (org.extraExecSecondsAvailable) { + quotaSecondsAllTypes += org.extraExecSecondsAvailable; + } + + if (org.giftedExecSecondsAvailable) { + quotaSecondsAllTypes += org.giftedExecSecondsAvailable; + } + + const now = new Date(); + const currentYear = now.getFullYear(); + const currentMonth = String(now.getUTCMonth() + 1).padStart(2, "0"); + const currentPeriod = `${currentYear}-${currentMonth}`; + + let usageSecondsExtra = 0; + if (org.extraExecSeconds) { + const actualUsageExtra = org.extraExecSeconds[currentPeriod]; + if (actualUsageExtra) { + usageSecondsExtra = actualUsageExtra; + } + } + const maxExecSecsExtra = org.quotas.extraExecMinutes * 60; + // Cap usage at quota for display purposes + if (usageSecondsExtra > maxExecSecsExtra) { + usageSecondsExtra = maxExecSecsExtra; + } + if (usageSecondsExtra) { + // Quota for extra = this month's usage + remaining available + quotaSecondsAllTypes += usageSecondsExtra; + } + + let usageSecondsGifted = 0; + if (org.giftedExecSeconds) { + const actualUsageGifted = org.giftedExecSeconds[currentPeriod]; + if (actualUsageGifted) { + usageSecondsGifted = actualUsageGifted; + } + } + const maxExecSecsGifted = org.quotas.giftedExecMinutes * 60; + // Cap usage at quota for display purposes + if (usageSecondsGifted > maxExecSecsGifted) { + usageSecondsGifted = maxExecSecsGifted; + } + if (usageSecondsGifted) { + // Quota for gifted = this month's usage + remaining available + quotaSecondsAllTypes += usageSecondsGifted; + } + + const hasQuota = Boolean(quotaSecondsAllTypes); + + return hasQuota; +} +export function hasStorageQuota(org: OrgData | null | undefined) { + return !!org?.quotas.storageQuota; +} diff --git a/frontend/src/features/meters/index.ts b/frontend/src/features/meters/index.ts new file mode 100644 index 0000000000..104150f862 --- /dev/null +++ b/frontend/src/features/meters/index.ts @@ -0,0 +1,2 @@ +import "./execution-minutes/execution-minute-meter"; +import "./storage/storage-meter"; diff --git a/frontend/src/features/meters/storage/colors.ts b/frontend/src/features/meters/storage/colors.ts new file mode 100644 index 0000000000..0862fda7a2 --- /dev/null +++ b/frontend/src/features/meters/storage/colors.ts @@ -0,0 +1,32 @@ +import { type Color } from "../utils/colors"; + +import { tw } from "@/utils/tailwind"; + +export type StorageType = + | "default" + | "crawls" + | "uploads" + | "archivedItems" + | "browserProfiles" + | "runningTime" + | "misc"; + +export const storageColorClasses = { + default: tw`text-neutral-600`, + crawls: tw`text-lime-500`, + uploads: tw`text-sky-500`, + archivedItems: tw`text-primary-500`, + browserProfiles: tw`text-orange-500`, + runningTime: tw`text-blue-600`, + misc: tw`text-gray-400`, +}; + +export const storageColors = { + default: { primary: "neutral-600", border: "neutral-700" }, + crawls: { primary: "lime-500", border: "lime-700" }, + uploads: { primary: "sky-500", border: "sky-700" }, + archivedItems: { primary: "primary-500", border: "primary-700" }, + browserProfiles: { primary: "orange-500", border: "orange-700" }, + runningTime: { primary: "blue-600", border: "blue-700" }, + misc: { primary: "gray-400", border: "gray-600" }, +} as const satisfies Record; diff --git a/frontend/src/features/meters/storage/storage-meter.ts b/frontend/src/features/meters/storage/storage-meter.ts new file mode 100644 index 0000000000..73047ff131 --- /dev/null +++ b/frontend/src/features/meters/storage/storage-meter.ts @@ -0,0 +1,162 @@ +import { localized, msg } from "@lit/localize"; +import { html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { when } from "lit/directives/when.js"; + +import { storageColors } from "./colors"; +import { tooltipRow } from "./tooltip"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import { type Color } from "@/features/meters/utils/colors"; +import { renderLegendColor } from "@/features/meters/utils/legend"; +import { tooltipContent } from "@/features/meters/utils/tooltip"; +import { type Metrics } from "@/types/org"; + +const STORAGE_TYPES = ["crawls", "uploads", "browserProfiles", "misc"] as const; +type StorageType = (typeof STORAGE_TYPES)[number]; + +@customElement("btrix-storage-meter") +@localized() +export class StorageMeter extends BtrixElement { + @property({ type: Object }) + metrics?: Metrics; + + render() { + if (!this.metrics) return; + return this.renderStorageMeter(this.metrics); + } + + private readonly renderStorageMeter = (metrics: Metrics) => { + const hasQuota = Boolean(metrics.storageQuotaBytes); + const isStorageFull = + hasQuota && metrics.storageUsedBytes >= metrics.storageQuotaBytes; + const misc = metrics.storageUsedSeedFiles + metrics.storageUsedThumbnails; + + const values = { + crawls: metrics.storageUsedCrawls, + uploads: metrics.storageUsedUploads, + browserProfiles: metrics.storageUsedProfiles, + misc: misc, + } satisfies Record; + + const titles = { + crawls: msg("Crawls"), + uploads: msg("Uploads"), + browserProfiles: msg("Profiles"), + misc: msg("Miscellaneous"), + } satisfies Record; + + const nonZeroValues = STORAGE_TYPES.filter((type) => values[type] > 0); + + const renderBar = ( + values: Record, + titles: Record, + colors: Record, + key: StorageType, + ) => { + return html` + + ${tooltipContent({ + title: html`${renderLegendColor(colors[key])}${titles[key]}`, + value: this.localize.bytes(values[key], { + unitDisplay: "narrow", + }), + content: + nonZeroValues.length > 1 + ? html`
+ ${nonZeroValues.map((type) => + tooltipRow( + titles[type], + values[type], + type === key, + colors[type], + ), + )} +
+ ${tooltipRow( + msg("All used storage"), + metrics.storageUsedBytes, + )}` + : undefined, + })} +
+ `; + }; + + return html` +
+ ${when( + isStorageFull, + () => html` +
+ + ${msg("Storage is Full")} +
+ `, + () => + hasQuota + ? html` + ${this.localize.bytes( + metrics.storageQuotaBytes - metrics.storageUsedBytes, + )} + ${msg("available")} + ` + : "", + )} +
+ ${when( + hasQuota, + () => html` +
+ + ${nonZeroValues.map((type) => + when(values[type], () => + renderBar(values, titles, storageColors, type), + ), + )} + +
+ +
+
+ ${msg("Available Storage")} + ${this.localize.bytes( + metrics.storageQuotaBytes - metrics.storageUsedBytes, + { + unitDisplay: "narrow", + }, + )} +
+
+
+
+
+ ${this.localize.bytes(metrics.storageUsedBytes, { + unitDisplay: "narrow", + })} + ${this.localize.bytes(metrics.storageQuotaBytes, { + unitDisplay: "narrow", + })} +
+
+ `, + )} + `; + }; +} diff --git a/frontend/src/features/meters/storage/tooltip.ts b/frontend/src/features/meters/storage/tooltip.ts new file mode 100644 index 0000000000..098323612e --- /dev/null +++ b/frontend/src/features/meters/storage/tooltip.ts @@ -0,0 +1,26 @@ +import clsx from "clsx"; +import { html } from "lit"; + +import { renderLegendColor } from "@/features/meters/utils/legend"; +import localize from "@/utils/localize"; +import { tw } from "@/utils/tailwind"; + +export const tooltipRow = ( + title: string, + value: number, + highlight = false, + color?: { primary: string; border: string }, +) => html` +

+ ${color ? renderLegendColor(color) : null}${title} + ${localize.bytes(value)} +

+`; diff --git a/frontend/src/features/meters/utils/colors.ts b/frontend/src/features/meters/utils/colors.ts new file mode 100644 index 0000000000..f663285254 --- /dev/null +++ b/frontend/src/features/meters/utils/colors.ts @@ -0,0 +1,36 @@ +type ShoelaceColor = + | "neutral" + | "gray" + | "primary" + | "red" + | "orange" + | "amber" + | "yellow" + | "lime" + | "green" + | "emerald" + | "teal" + | "cyan" + | "sky" + | "blue" + | "indigo" + | "violet" + | "purple" + | "fuchsia" + | "pink" + | "rose"; + +type ShoelaceValue = + | "50" + | "100" + | "200" + | "300" + | "400" + | "500" + | "600" + | "700" + | "800" + | "900" + | "950"; + +export type Color = `${ShoelaceColor}-${ShoelaceValue}`; diff --git a/frontend/src/features/meters/utils/legend.ts b/frontend/src/features/meters/utils/legend.ts new file mode 100644 index 0000000000..81159b5cdd --- /dev/null +++ b/frontend/src/features/meters/utils/legend.ts @@ -0,0 +1,11 @@ +import { html } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; + +export const renderLegendColor = (color: { primary: string; border: string }) => + html``; diff --git a/frontend/src/features/meters/utils/tooltip.ts b/frontend/src/features/meters/utils/tooltip.ts new file mode 100644 index 0000000000..a0edee2418 --- /dev/null +++ b/frontend/src/features/meters/utils/tooltip.ts @@ -0,0 +1,16 @@ +import { html, type TemplateResult } from "lit"; + +export const tooltipContent = ({ + title, + value, + content, +}: { + title: string | TemplateResult; + value: string | TemplateResult; + content: string | TemplateResult | undefined; +}) => + html`
+ ${title} + ${value} +
+ ${content}`; diff --git a/frontend/src/features/org/usage-history-table.ts b/frontend/src/features/org/usage-history-table.ts index 9aef8702fc..87404424f8 100644 --- a/frontend/src/features/org/usage-history-table.ts +++ b/frontend/src/features/org/usage-history-table.ts @@ -5,7 +5,10 @@ import { customElement } from "lit/decorators.js"; import { BtrixElement } from "@/classes/BtrixElement"; import type { GridColumn, GridItem } from "@/components/ui/data-grid/types"; import { noData } from "@/strings/ui"; -import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter"; +import { + humanizeExecutionSeconds, + humanizeSeconds, +} from "@/utils/executionTimeFormatter"; enum Field { Month = "month", @@ -116,7 +119,7 @@ export class UsageHistoryTable extends BtrixElement { if (org.quotas.maxExecMinutesPerMonth) { maxMonthlySeconds = org.quotas.maxExecMinutesPerMonth * 60; } - if (monthlySecondsUsed > maxMonthlySeconds) { + if (maxMonthlySeconds !== 0 && monthlySecondsUsed > maxMonthlySeconds) { monthlySecondsUsed = maxMonthlySeconds; } @@ -125,7 +128,7 @@ export class UsageHistoryTable extends BtrixElement { if (org.quotas.extraExecMinutes) { maxExtraSeconds = org.quotas.extraExecMinutes * 60; } - if (extraSecondsUsed > maxExtraSeconds) { + if (maxExtraSeconds !== 0 && extraSecondsUsed > maxExtraSeconds) { extraSecondsUsed = maxExtraSeconds; } @@ -134,14 +137,16 @@ export class UsageHistoryTable extends BtrixElement { if (org.quotas.giftedExecMinutes) { maxGiftedSeconds = org.quotas.giftedExecMinutes * 60; } - if (giftedSecondsUsed > maxGiftedSeconds) { + if (maxGiftedSeconds !== 0 && giftedSecondsUsed > maxGiftedSeconds) { giftedSecondsUsed = maxGiftedSeconds; } let totalSecondsUsed = org.crawlExecSeconds?.[mY] || 0; const totalMaxQuota = - maxMonthlySeconds + maxExtraSeconds + maxGiftedSeconds; - if (totalSecondsUsed > totalMaxQuota) { + maxMonthlySeconds !== 0 + ? maxMonthlySeconds + maxExtraSeconds + maxGiftedSeconds + : 0; + if (totalMaxQuota !== 0 && totalSecondsUsed > totalMaxQuota) { totalSecondsUsed = totalMaxQuota; } @@ -168,7 +173,17 @@ export class UsageHistoryTable extends BtrixElement { private readonly renderSecondsForField = (field: `${Field}`) => - ({ item }: { item: GridItem }) => html` - ${item[field] ? humanizeExecutionSeconds(+item[field]) : noData} - `; + ({ item }: { item: GridItem }) => { + if (!item[field]) return html`${noData}`; + + if (field === Field.ElapsedTime) + return html`${humanizeSeconds(+item[field], { displaySeconds: true })}`; + + if (field === Field.BillableExecutionTime) + return html`${humanizeExecutionSeconds(+item[field])}`; + + return html`${humanizeExecutionSeconds(+item[field], { + displaySeconds: true, + })}`; + }; } diff --git a/frontend/src/pages/org/dashboard.ts b/frontend/src/pages/org/dashboard.ts index 1780b0541c..b6b88a4df3 100644 --- a/frontend/src/pages/org/dashboard.ts +++ b/frontend/src/pages/org/dashboard.ts @@ -17,41 +17,20 @@ import type { SelectNewDialogEvent } from "."; import { BtrixElement } from "@/classes/BtrixElement"; import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; import { type CollectionSavedEvent } from "@/features/collections/collection-edit-dialog"; +import { storageColorClasses } from "@/features/meters/storage/colors"; import { pageHeading } from "@/layouts/page"; import { pageHeader } from "@/layouts/pageHeader"; import { RouteNamespace } from "@/routes"; import type { APIPaginatedList, APISortQuery } from "@/types/api"; import { CollectionAccess, type Collection } from "@/types/collection"; +import { type Metrics } from "@/types/org"; import { SortDirection } from "@/types/utils"; -import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter"; import { richText } from "@/utils/rich-text"; import { tw } from "@/utils/tailwind"; import { timeoutCache } from "@/utils/timeoutCache"; import { toShortUrl } from "@/utils/url-helpers"; import { cached } from "@/utils/weakCache"; -type Metrics = { - storageUsedBytes: number; - storageUsedCrawls: number; - storageUsedUploads: number; - storageUsedProfiles: number; - storageUsedSeedFiles: number; - storageUsedThumbnails: number; - storageQuotaBytes: number; - archivedItemCount: number; - crawlCount: number; - uploadCount: number; - pageCount: number; - crawlPageCount: number; - uploadPageCount: number; - profileCount: number; - workflowsRunningCount: number; - maxConcurrentCrawls: number; - workflowsQueuedCount: number; - collectionsCount: number; - publicCollectionsCount: number; -}; - enum CollectionGridView { All = "all", Public = "public", @@ -80,16 +59,6 @@ export class Dashboard extends BtrixElement { // Used for busting cache when updating visible collection cacheBust = 0; - private readonly colors = { - default: tw`text-neutral-600`, - crawls: tw`text-lime-500`, - uploads: tw`text-sky-500`, - archivedItems: tw`text-primary-500`, - browserProfiles: tw`text-orange-500`, - runningTime: tw`text-blue-600`, - misc: tw`text-gray-400`, - }; - private readonly collections = new Task(this, { task: cached( async ([orgId, collectionsView, collectionPage]) => { @@ -262,7 +231,7 @@ export class Dashboard extends BtrixElement { iconProps: { name: "gear-wide-connected", - class: this.colors.crawls, + class: storageColorClasses.crawls, }, button: { url: "/items/crawl", @@ -276,7 +245,10 @@ export class Dashboard extends BtrixElement { singleLabel: msg("Upload"), pluralLabel: msg("Uploads"), - iconProps: { name: "upload", class: this.colors.uploads }, + iconProps: { + name: "upload", + class: storageColorClasses.uploads, + }, button: { url: "/items/upload", }, @@ -290,7 +262,7 @@ export class Dashboard extends BtrixElement { pluralLabel: msg("Browser Profiles"), iconProps: { name: "window-fullscreen", - class: this.colors.browserProfiles, + class: storageColorClasses.browserProfiles, }, button: { url: "/browser-profiles", @@ -307,7 +279,7 @@ export class Dashboard extends BtrixElement { pluralLabel: msg("Archived Items"), iconProps: { name: "file-zip-fill", - class: this.colors.archivedItems, + class: storageColorClasses.archivedItems, }, button: { url: "/items", @@ -369,7 +341,7 @@ export class Dashboard extends BtrixElement { pluralLabel: msg("Pages Crawled"), iconProps: { name: "file-richtext-fill", - class: this.colors.crawls, + class: storageColorClasses.crawls, }, })} ${this.renderStat({ @@ -378,7 +350,7 @@ export class Dashboard extends BtrixElement { pluralLabel: msg("Pages Uploaded"), iconProps: { name: "file-richtext-fill", - class: this.colors.uploads, + class: storageColorClasses.uploads, }, })} ${this.renderStat({ @@ -560,7 +532,7 @@ export class Dashboard extends BtrixElement {
${msg("Miscellaneous")} @@ -621,360 +593,15 @@ export class Dashboard extends BtrixElement { } private renderStorageMeter(metrics: Metrics) { - const hasQuota = Boolean(metrics.storageQuotaBytes); - const isStorageFull = - hasQuota && metrics.storageUsedBytes >= metrics.storageQuotaBytes; - const misc = metrics.storageUsedSeedFiles + metrics.storageUsedThumbnails; - - const renderBar = ( - value: number, - label: string, - colorClassname: string, - ) => html` - -
${label}
-
-

- ${this.localize.bytes(value, { - unitDisplay: "narrow", - })} -
- ${this.renderPercentage(value / metrics.storageUsedBytes)} -

-
- `; - return html` -
- ${when( - isStorageFull, - () => html` -
- - ${msg("Storage is Full")} -
- `, - () => - hasQuota - ? html` - ${this.localize.bytes( - metrics.storageQuotaBytes - metrics.storageUsedBytes, - )} - ${msg("available")} - ` - : "", - )} -
- ${when( - hasQuota, - () => html` -
- - ${when(metrics.storageUsedCrawls, () => - renderBar( - metrics.storageUsedCrawls, - msg("Crawls"), - this.colors.crawls, - ), - )} - ${when(metrics.storageUsedUploads, () => - renderBar( - metrics.storageUsedUploads, - msg("Uploads"), - this.colors.uploads, - ), - )} - ${when(metrics.storageUsedProfiles, () => - renderBar( - metrics.storageUsedProfiles, - msg("Profiles"), - this.colors.browserProfiles, - ), - )} - ${when(misc, () => - renderBar(misc, msg("Miscellaneous"), this.colors.misc), - )} -
- -
-
- ${msg("Available")} -
-
-

- ${this.renderPercentage( - (metrics.storageQuotaBytes - metrics.storageUsedBytes) / - metrics.storageQuotaBytes, - )} -

-
-
-
-
- ${this.localize.bytes(metrics.storageUsedBytes, { - unitDisplay: "narrow", - })} - ${this.localize.bytes(metrics.storageQuotaBytes, { - unitDisplay: "narrow", - })} -
-
- `, - )} - `; + return html``; } - private renderCrawlingMeter(_metrics: Metrics) { - if (!this.org) return; - - let quotaSeconds = 0; - - 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; - } - - let quotaSecondsGifted = 0; - if (this.org.giftedExecSecondsAvailable) { - quotaSecondsGifted = this.org.giftedExecSecondsAvailable; - quotaSecondsAllTypes += this.org.giftedExecSecondsAvailable; - } - - const now = new Date(); - const currentYear = now.getFullYear(); - const currentMonth = String(now.getUTCMonth() + 1).padStart(2, "0"); - const currentPeriod = `${currentYear}-${currentMonth}`; - - let usageSeconds = 0; - if (this.org.monthlyExecSeconds) { - const actualUsage = this.org.monthlyExecSeconds[currentPeriod]; - if (actualUsage) { - usageSeconds = actualUsage; - } - } - - if (usageSeconds > quotaSeconds) { - usageSeconds = quotaSeconds; - } - - let usageSecondsAllTypes = 0; - if (this.org.monthlyExecSeconds) { - const actualUsage = this.org.monthlyExecSeconds[currentPeriod]; - if (actualUsage) { - usageSecondsAllTypes = actualUsage; - } - } - - let usageSecondsExtra = 0; - if (this.org.extraExecSeconds) { - const actualUsageExtra = this.org.extraExecSeconds[currentPeriod]; - if (actualUsageExtra) { - usageSecondsExtra = actualUsageExtra; - } - } - const maxExecSecsExtra = this.org.quotas.extraExecMinutes * 60; - // Cap usage at quota for display purposes - if (usageSecondsExtra > maxExecSecsExtra) { - usageSecondsExtra = maxExecSecsExtra; - } - if (usageSecondsExtra) { - // Quota for extra = this month's usage + remaining available - quotaSecondsAllTypes += usageSecondsExtra; - quotaSecondsExtra += usageSecondsExtra; - } - - let usageSecondsGifted = 0; - if (this.org.giftedExecSeconds) { - const actualUsageGifted = this.org.giftedExecSeconds[currentPeriod]; - if (actualUsageGifted) { - usageSecondsGifted = actualUsageGifted; - } - } - const maxExecSecsGifted = this.org.quotas.giftedExecMinutes * 60; - // Cap usage at quota for display purposes - if (usageSecondsGifted > maxExecSecsGifted) { - usageSecondsGifted = maxExecSecsGifted; - } - if (usageSecondsGifted) { - // Quota for gifted = this month's usage + remaining available - quotaSecondsAllTypes += usageSecondsGifted; - quotaSecondsGifted += usageSecondsGifted; - } - - const hasQuota = Boolean(quotaSecondsAllTypes); - const isReached = hasQuota && usageSecondsAllTypes >= quotaSecondsAllTypes; - - const maxTotalTime = quotaSeconds + quotaSecondsExtra + quotaSecondsGifted; - if (isReached) { - usageSecondsAllTypes = maxTotalTime; - quotaSecondsAllTypes = maxTotalTime; - } - - const hasExtra = - usageSecondsExtra || - this.org.extraExecSecondsAvailable || - usageSecondsGifted || - this.org.giftedExecSecondsAvailable; - - const renderBar = ( - /** Time in Seconds */ - used: number, - quota: number, - label: string, - color: string, - divided = true, - ) => { - if (divided) { - return html` -
${label}
-
-

- ${humanizeExecutionSeconds(used, { displaySeconds: true })} - ${msg("of")} -
- ${humanizeExecutionSeconds(quota, { displaySeconds: true })} -

-
`; - } else { - return html` -
${label}
-
-

- ${humanizeExecutionSeconds(used, { displaySeconds: true })} -
- ${this.renderPercentage(used / quota)} -

-
`; - } - }; - return html` -
- ${when( - isReached, - () => html` -
- - ${msg("Execution Minutes Quota Reached")} -
- `, - () => - hasQuota && this.org - ? html` - - ${humanizeExecutionSeconds( - quotaSeconds - - usageSeconds + - this.org.extraExecSecondsAvailable + - this.org.giftedExecSecondsAvailable, - { style: "short", round: "down" }, - )} - ${msg("remaining")} - - ` - : "", - )} -
- ${when( - hasQuota && this.org, - (org) => html` -
- - ${when(usageSeconds || quotaSeconds, () => - renderBar( - usageSeconds > quotaSeconds ? quotaSeconds : usageSeconds, - hasExtra ? quotaSeconds : quotaSecondsAllTypes, - msg("Monthly Execution Time Used"), - "lime", - hasExtra ? true : false, - ), - )} - ${when(usageSecondsGifted || org.giftedExecSecondsAvailable, () => - renderBar( - usageSecondsGifted > quotaSecondsGifted - ? quotaSecondsGifted - : usageSecondsGifted, - quotaSecondsGifted, - msg("Gifted Execution Time Used"), - "blue", - ), - )} - ${when(usageSecondsExtra || org.extraExecSecondsAvailable, () => - renderBar( - usageSecondsExtra > quotaSecondsExtra - ? quotaSecondsExtra - : usageSecondsExtra, - quotaSecondsExtra, - msg("Extra Execution Time Used"), - "violet", - ), - )} -
- -
-
${msg("Monthly Execution Time Remaining")}
-
- ${humanizeExecutionSeconds(quotaSeconds - usageSeconds, { - displaySeconds: true, - })} - | - ${this.renderPercentage( - (quotaSeconds - usageSeconds) / quotaSeconds, - )} -
-
-
-
-
- - ${humanizeExecutionSeconds(usageSecondsAllTypes, { - style: "short", - })} - - - ${humanizeExecutionSeconds(quotaSecondsAllTypes, { - style: "short", - })} - -
-
- `, - )} - `; + private renderCrawlingMeter(metrics: Metrics) { + return html``; } private renderCard( @@ -1059,12 +686,6 @@ export class Dashboard extends BtrixElement { `; - private renderPercentage(ratio: number) { - const percent = ratio * 100; - if (percent < 1) return `<1%`; - return `${percent.toFixed(2)}%`; - } - private async fetchMetrics() { try { const data = await this.api.fetch( diff --git a/frontend/src/pages/org/settings/components/billing.ts b/frontend/src/pages/org/settings/components/billing.ts index 8f273e5a0b..09528edbc2 100644 --- a/frontend/src/pages/org/settings/components/billing.ts +++ b/frontend/src/pages/org/settings/components/billing.ts @@ -9,9 +9,14 @@ import { when } from "lit/directives/when.js"; import capitalize from "lodash/fp/capitalize"; import { BtrixElement } from "@/classes/BtrixElement"; +import { + hasExecutionMinuteQuota, + hasStorageQuota, +} from "@/features/meters/has-quotas"; import { columns } from "@/layouts/columns"; import { SubscriptionStatus, type BillingPortal } from "@/types/billing"; -import type { OrgData, OrgQuotas } from "@/types/org"; +import type { Metrics, OrgData, OrgQuotas } from "@/types/org"; +import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter"; import { pluralOf } from "@/utils/pluralize"; import { tw } from "@/utils/tailwind"; @@ -77,18 +82,42 @@ export class OrgSettingsBilling extends BtrixElement { console.debug(e); throw new Error( - msg("Sorry, couldn't retrieve current plan at this time."), + msg("Sorry, couldn’t retrieve current plan at this time."), ); } }, args: () => [this.appState] as const, }); + private readonly metrics = new Task(this, { + task: async ([orgId]) => { + const metrics = await this.api.fetch( + `/orgs/${orgId}/metrics`, + ); + if (!metrics) { + throw new Error("Missing metrics"); + } + + return metrics; + }, + args: () => [this.org?.id] as const, + }); + render() { const manageSubscriptionMessage = msg( str`Click “${this.portalUrlLabel}” to view plan details, payment methods, and billing information.`, ); + const meterPendingExecutionTime = html` + + + `; + + const meterPendingStorage = html` + + + `; + return html`
${columns([ @@ -279,6 +308,71 @@ export class OrgSettingsBilling extends BtrixElement { ], ])}
+
+
+

${msg("Usage")}

+
+ +

+ ${msg("Execution time")} +

+ ${when( + hasExecutionMinuteQuota(this.org), + () => + this.metrics.render({ + initial: () => meterPendingExecutionTime, + complete: (metrics) => + html` `, + pending: () => meterPendingExecutionTime, + }), + () => { + if (!this.org?.crawlExecSeconds) + return html``; + + const now = new Date(); + const currentYear = now.getFullYear(); + const currentMonth = String(now.getUTCMonth() + 1).padStart(2, "0"); + const currentPeriod = `${currentYear}-${currentMonth}`; + + const minutesUsed = html`${humanizeExecutionSeconds( + this.org.crawlExecSeconds[currentPeriod] || 0, + )}`; + return html`${msg(html`${minutesUsed} this month`)}`; + }, + )} +

+ ${msg("Storage")} +

+ ${when( + hasStorageQuota(this.org), + () => + this.metrics.render({ + initial: () => meterPendingStorage, + complete: (metrics) => + when( + metrics.storageQuotaBytes, + () => + html` `, + ), + pending: () => meterPendingStorage, + }), + () => { + if (!this.org?.bytesStored) + return html``; + + const bytesUsed = this.localize.bytes(this.org.bytesStored); + return html`
${bytesUsed}
`; + }, + )} +

${msg("Usage History")}

@@ -366,13 +460,14 @@ export class OrgSettingsBilling extends BtrixElement { }; private readonly renderMonthlyQuotas = (quotas: OrgQuotas) => { - const maxExecMinutesPerMonth = - quotas.maxExecMinutesPerMonth && - this.localize.number(quotas.maxExecMinutesPerMonth, { + const maxExecMinutesPerMonth = this.localize.number( + quotas.maxExecMinutesPerMonth, + { style: "unit", unit: "minute", unitDisplay: "long", - }); + }, + ); const maxPagesPerCrawl = quotas.maxPagesPerCrawl && `${this.localize.number(quotas.maxPagesPerCrawl)} ${pluralOf("pages", quotas.maxPagesPerCrawl)}`; @@ -381,18 +476,20 @@ export class OrgSettingsBilling extends BtrixElement { msg( str`${this.localize.number(quotas.maxConcurrentCrawls)} concurrent ${pluralOf("crawls", quotas.maxConcurrentCrawls)}`, ); - const storageBytesText = quotas.storageQuota - ? this.localize.bytes(quotas.storageQuota) - : msg("Unlimited"); + const storageBytesText = this.localize.bytes(quotas.storageQuota); return html`
  • - ${msg( - str`${maxExecMinutesPerMonth || msg("Unlimited minutes")} of execution time`, - )} + ${quotas.maxExecMinutesPerMonth + ? msg(str`${maxExecMinutesPerMonth} of execution time`) + : msg("Unlimited execution time")} +
  • +
  • + ${quotas.storageQuota + ? msg(str`${storageBytesText} of disk space`) + : msg("Unlimited disk space")}
  • -
  • ${msg(str`${storageBytesText} of disk space`)}
  • ${msg(str`${maxPagesPerCrawl || msg("Unlimited pages")} per crawl`)}
  • diff --git a/frontend/src/pages/org/settings/settings.ts b/frontend/src/pages/org/settings/settings.ts index 61dc72c5e1..007bdb3c04 100644 --- a/frontend/src/pages/org/settings/settings.ts +++ b/frontend/src/pages/org/settings/settings.ts @@ -91,7 +91,7 @@ export class OrgSettings extends BtrixElement { return { information: msg("General"), members: msg("Members"), - billing: msg("Billing"), + billing: msg("Billing & Usage"), "crawling-defaults": msg("Crawling Defaults"), }; } diff --git a/frontend/src/strings/numbers.ts b/frontend/src/strings/numbers.ts new file mode 100644 index 0000000000..cfa38e5804 --- /dev/null +++ b/frontend/src/strings/numbers.ts @@ -0,0 +1,6 @@ +export function renderPercentage(ratio: number) { + const percent = ratio * 100; + if (percent === 0) return `0%`; + if (percent < 1) return `<1%`; + return `${percent.toFixed(2)}%`; +} diff --git a/frontend/src/types/org.ts b/frontend/src/types/org.ts index 3a50aaec71..51757784f2 100644 --- a/frontend/src/types/org.ts +++ b/frontend/src/types/org.ts @@ -115,3 +115,25 @@ export const publicOrgCollectionsSchema = z.object({ collections: z.array(publicCollectionSchema), }); export type PublicOrgCollections = z.infer; + +export type Metrics = { + storageUsedBytes: number; + storageUsedCrawls: number; + storageUsedUploads: number; + storageUsedProfiles: number; + storageUsedSeedFiles: number; + storageUsedThumbnails: number; + storageQuotaBytes: number; + archivedItemCount: number; + crawlCount: number; + uploadCount: number; + pageCount: number; + crawlPageCount: number; + uploadPageCount: number; + profileCount: number; + workflowsRunningCount: number; + maxConcurrentCrawls: number; + workflowsQueuedCount: number; + collectionsCount: number; + publicCollectionsCount: number; +}; diff --git a/frontend/src/utils/executionTimeFormatter.test.ts b/frontend/src/utils/executionTimeFormatter.test.ts index f4e178e07b..af5a5d86fe 100644 --- a/frontend/src/utils/executionTimeFormatter.test.ts +++ b/frontend/src/utils/executionTimeFormatter.test.ts @@ -7,40 +7,52 @@ import { describe("formatHours", () => { it("returns a time in hours and minutes when given a time over an hour", () => { - expect(humanizeSeconds(12_345, "en-US")).to.equal("3h 26m"); + expect(humanizeSeconds(12_345, { locale: "en-US" })).to.equal("3h 26m"); }); it("returns 1m when given a time under a minute", () => { - expect(humanizeSeconds(24, "en-US")).to.equal("1m"); + expect(humanizeSeconds(24, { locale: "en-US" })).to.equal("1m"); }); it("returns seconds given a time under a minute when not rounding", () => { - expect(humanizeSeconds(24, "en-US")).to.equal("1m"); + expect(humanizeSeconds(24, { locale: "en-US" })).to.equal("1m"); }); it("returns 0m and seconds when given a time under a minute with seconds on", () => { - expect(humanizeSeconds(24, "en-US", true)).to.equal("0m 24s"); + expect( + humanizeSeconds(24, { locale: "en-US", displaySeconds: true }), + ).to.equal("0m 24s"); }); it("returns minutes when given a time under an hour", () => { - expect(humanizeSeconds(1_234, "en-US")).to.equal("21m"); + expect(humanizeSeconds(1_234, { locale: "en-US" })).to.equal("21m"); }); it("returns just hours when given a time exactly in hours", () => { - expect(humanizeSeconds(3_600, "en-US")).to.equal("1h"); - expect(humanizeSeconds(44_442_000, "en-US")).to.equal("12,345h"); + expect(humanizeSeconds(3_600, { locale: "en-US" })).to.equal("1h"); + expect(humanizeSeconds(44_442_000, { locale: "en-US" })).to.equal( + "12,345h", + ); }); it("handles different locales correctly", () => { - expect(humanizeSeconds(44_442_000_000, "en-IN")).to.equal("1,23,45,000h"); - expect(humanizeSeconds(44_442_000_000, "pt-BR")).to.equal("12.345.000 h"); - expect(humanizeSeconds(44_442_000_000, "de-DE")).to.equal( + expect(humanizeSeconds(44_442_000_000, { locale: "en-IN" })).to.equal( + "1,23,45,000h", + ); + expect(humanizeSeconds(44_442_000_000, { locale: "pt-BR" })).to.equal( + "12.345.000 h", + ); + expect(humanizeSeconds(44_442_000_000, { locale: "de-DE" })).to.equal( "12.345.000 Std.", ); - expect(humanizeSeconds(44_442_000_000, "ar-EG")).to.equal("١٢٬٣٤٥٬٠٠٠ س"); + expect(humanizeSeconds(44_442_000_000, { locale: "ar-EG" })).to.equal( + "١٢٬٣٤٥٬٠٠٠ س", + ); }); it("formats zero time as expected", () => { - expect(humanizeSeconds(0, "en-US")).to.equal("0m"); + expect(humanizeSeconds(0, { locale: "en-US" })).to.equal("0m"); }); it("formats zero time as expected", () => { - expect(humanizeSeconds(0, "en-US", true)).to.equal("0s"); + expect( + humanizeSeconds(0, { locale: "en-US", displaySeconds: true }), + ).to.equal("0s"); }); it("formats negative time as expected", () => { - expect(() => humanizeSeconds(-100, "en-US")).to.throw( + expect(() => humanizeSeconds(-100, { locale: "en-US" })).to.throw( "humanizeSeconds in unimplemented for negative times", ); }); @@ -53,8 +65,8 @@ describe("humanizeExecutionSeconds", () => { parentNode, }); expect(el.getAttribute("title")).to.equal("20,576,132 minutes"); - expect(el.textContent?.trim()).to.equal("21M minutes\u00a0(342,935h 32m)"); - expect(parentNode.innerText).to.equal("21M minutes\u00a0(342,935h 32m)"); + expect(el.textContent?.trim()).to.equal("21M minutes"); + expect(parentNode.innerText).to.equal("21M minutes"); }); it("shows a short version when set", async () => { @@ -65,9 +77,7 @@ describe("humanizeExecutionSeconds", () => { parentNode, }, ); - expect(el.getAttribute("title")).to.equal( - "20,576,132 minutes\u00a0(342,935h 32m)", - ); + expect(el.getAttribute("title")).to.equal("20,576,132 minutes"); expect(el.textContent?.trim()).to.equal("21M min"); expect(parentNode.innerText).to.equal("21M min"); }); @@ -108,12 +118,12 @@ describe("humanizeExecutionSeconds", () => { parentNode, }, ); - expect(el.textContent?.trim()).to.equal("<1 minute\u00a0(0m 24s)"); - expect(parentNode.innerText).to.equal("<1 minute\u00a0(0m 24s)"); + expect(el.textContent?.trim()).to.equal("0m 24s"); + expect(parentNode.innerText).to.equal("0m 24s"); }); it("formats zero seconds", async () => { const parentNode = document.createElement("div"); - const el = await fixture( + await fixture( humanizeExecutionSeconds(0, { displaySeconds: true, }), @@ -121,7 +131,6 @@ describe("humanizeExecutionSeconds", () => { parentNode, }, ); - expect(el.textContent?.trim()).to.equal("0 minutes"); expect(parentNode.innerText).to.equal("0 minutes"); }); }); diff --git a/frontend/src/utils/executionTimeFormatter.ts b/frontend/src/utils/executionTimeFormatter.ts index d80afdc783..a41f9d045b 100644 --- a/frontend/src/utils/executionTimeFormatter.ts +++ b/frontend/src/utils/executionTimeFormatter.ts @@ -15,15 +15,26 @@ import localize from "./localize"; */ export function humanizeSeconds( seconds: number, - locale?: string, - displaySeconds = false, - unitDisplay: "narrow" | "short" | "long" = "narrow", + { + locale, + displaySeconds = false, + unitDisplay = "narrow", + maxUnit = "hour", + }: { + locale?: string; + displaySeconds?: boolean; + unitDisplay?: "narrow" | "short" | "long"; + maxUnit?: "hour" | "minute"; + } = {}, ) { if (seconds < 0) { throw new Error("humanizeSeconds in unimplemented for negative times"); } - const hours = Math.floor(seconds / 3600); - seconds -= hours * 3600; + let hours = 0; + if (maxUnit === "hour") { + hours = Math.floor(seconds / 3600); + seconds -= hours * 3600; + } // If displaying seconds, round minutes down, otherwise round up const minutes = displaySeconds ? Math.floor(seconds / 60) @@ -109,7 +120,15 @@ export const humanizeExecutionSeconds = ( maximumFractionDigits: 0, }); - const details = humanizeSeconds(seconds, locale, displaySeconds); + if (seconds === 0) { + return longMinuteFormatter.format(0); + } + + const details = humanizeSeconds(seconds, { + locale, + displaySeconds, + maxUnit: "minute", + }); const compactMinutes = compactMinuteFormatter.format(minutes); const fullMinutes = longMinuteFormatter.format(minutes); @@ -118,8 +137,11 @@ export const humanizeExecutionSeconds = ( ? seconds % 60 !== 0 : Math.floor(seconds / 60) === 0 && seconds % 60 !== 0; const formattedDetails = - detailsRelevant || seconds > 3600 ? `\u00a0(${details})` : nothing; - const prefix = detailsRelevant && seconds < 60 ? "<" : ""; + detailsRelevant || seconds > 3600 ? details : nothing; + const prefix = + (!displaySeconds && seconds < 60) || (displaySeconds && seconds < 1) + ? "<" + : ""; switch (style) { case "long": @@ -127,11 +149,10 @@ export const humanizeExecutionSeconds = ( title="${ifDefined( fullMinutes !== compactMinutes ? fullMinutes : undefined, )}" - >${prefix}${compactMinutes}${formattedDetails}${prefix}${detailsRelevant ? formattedDetails : compactMinutes}`; case "short": - return html`${prefix}${compactMinutes}`; }