` : ""}
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`
+ ${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`
-
-
-
-
- ${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),
- )}
-
-
-
-
-
-
- ${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`
-
-
-
- ${humanizeExecutionSeconds(used, { displaySeconds: true })}
- ${msg("of")}
-
- ${humanizeExecutionSeconds(quota, { displaySeconds: true })}
-
- `;
- } else {
- return html`
-
-
-
- ${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("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}`;
}