- Hides usage table from dashboard if billing is enabled - Shows usage table in billing settings - Updates usage table column headings - Fixes `portalUrl` task running unnecessarily
706 lines
23 KiB
TypeScript
706 lines
23 KiB
TypeScript
import { localized, msg } from "@lit/localize";
|
|
import type { SlSelectEvent } from "@shoelace-style/shoelace";
|
|
import type { PropertyValues, TemplateResult } from "lit";
|
|
import { customElement, property, state } from "lit/decorators.js";
|
|
import { ifDefined } from "lit/directives/if-defined.js";
|
|
import { when } from "lit/directives/when.js";
|
|
|
|
import type { SelectNewDialogEvent } from ".";
|
|
|
|
import type { AuthState } from "@/utils/AuthService";
|
|
import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter";
|
|
import LiteElement, { html } from "@/utils/LiteElement";
|
|
import type { OrgData, YearMonth } from "@/utils/orgs";
|
|
|
|
type Metrics = {
|
|
storageUsedBytes: number;
|
|
storageUsedCrawls: number;
|
|
storageUsedUploads: number;
|
|
storageUsedProfiles: number;
|
|
storageQuotaBytes: number;
|
|
archivedItemCount: number;
|
|
crawlCount: number;
|
|
uploadCount: number;
|
|
pageCount: number;
|
|
profileCount: number;
|
|
workflowsRunningCount: number;
|
|
maxConcurrentCrawls: number;
|
|
workflowsQueuedCount: number;
|
|
collectionsCount: number;
|
|
publicCollectionsCount: number;
|
|
};
|
|
|
|
@localized()
|
|
@customElement("btrix-dashboard")
|
|
export class Dashboard extends LiteElement {
|
|
@property({ type: Object })
|
|
authState!: AuthState;
|
|
|
|
@property({ type: Boolean })
|
|
isCrawler?: boolean;
|
|
|
|
@property({ type: Boolean })
|
|
isAdmin?: boolean;
|
|
|
|
@property({ type: String })
|
|
orgId!: string;
|
|
|
|
@property({ type: Object })
|
|
org: OrgData | null = null;
|
|
|
|
@state()
|
|
private metrics?: Metrics;
|
|
|
|
private readonly colors = {
|
|
default: "neutral",
|
|
crawls: "green",
|
|
uploads: "sky",
|
|
browserProfiles: "indigo",
|
|
runningTime: "blue",
|
|
};
|
|
|
|
willUpdate(changedProperties: PropertyValues<this>) {
|
|
if (changedProperties.has("orgId")) {
|
|
void this.fetchMetrics();
|
|
}
|
|
}
|
|
|
|
render() {
|
|
const hasQuota = Boolean(this.metrics?.storageQuotaBytes);
|
|
const quotaReached =
|
|
this.metrics &&
|
|
this.metrics.storageQuotaBytes > 0 &&
|
|
this.metrics.storageUsedBytes >= this.metrics.storageQuotaBytes;
|
|
|
|
return html`<header
|
|
class="mb-7 flex items-center justify-end gap-2 border-b pb-3"
|
|
>
|
|
<h1 class="mr-auto min-w-0 text-xl font-semibold leading-8">
|
|
${this.org?.name}
|
|
</h1>
|
|
${when(
|
|
this.isAdmin,
|
|
() =>
|
|
html` <sl-icon-button
|
|
href=${`${this.orgBasePath}/settings`}
|
|
class="size-8 text-lg"
|
|
name="gear"
|
|
label=${msg("Edit org settings")}
|
|
@click=${this.navLink}
|
|
></sl-icon-button>`,
|
|
)}
|
|
${when(
|
|
this.isCrawler,
|
|
() =>
|
|
html` <sl-dropdown
|
|
distance="4"
|
|
placement="bottom-end"
|
|
@sl-select=${(e: SlSelectEvent) => {
|
|
this.dispatchEvent(
|
|
new CustomEvent("select-new-dialog", {
|
|
detail: e.detail.item.value,
|
|
}) as SelectNewDialogEvent,
|
|
);
|
|
}}
|
|
>
|
|
<sl-button
|
|
slot="trigger"
|
|
size="small"
|
|
variant="primary"
|
|
caret
|
|
?disabled=${this.org?.readOnly}
|
|
>
|
|
<sl-icon slot="prefix" name="plus-lg"></sl-icon>
|
|
${msg("Create New...")}
|
|
</sl-button>
|
|
<sl-menu>
|
|
<sl-menu-item value="workflow"
|
|
>${msg("Crawl Workflow")}</sl-menu-item
|
|
>
|
|
<sl-menu-item
|
|
value="upload"
|
|
?disabled=${!this.metrics || quotaReached}
|
|
>${msg("Upload")}</sl-menu-item
|
|
>
|
|
<sl-menu-item value="collection">
|
|
${msg("Collection")}
|
|
</sl-menu-item>
|
|
<sl-menu-item
|
|
value="browser-profile"
|
|
?disabled=${!this.metrics || quotaReached}
|
|
>
|
|
${msg("Browser Profile")}
|
|
</sl-menu-item>
|
|
</sl-menu>
|
|
</sl-dropdown>`,
|
|
)}
|
|
</header>
|
|
<main>
|
|
<div class="mb-10 flex flex-col gap-6 md:flex-row">
|
|
${this.renderCard(
|
|
msg("Storage"),
|
|
(metrics) => html`
|
|
${this.renderStorageMeter(metrics)}
|
|
<dl>
|
|
${this.renderStat({
|
|
value: metrics.crawlCount,
|
|
secondaryValue: hasQuota
|
|
? ""
|
|
: html`<sl-format-bytes
|
|
value=${metrics.storageUsedCrawls}
|
|
></sl-format-bytes>`,
|
|
singleLabel: msg("Crawl"),
|
|
pluralLabel: msg("Crawls"),
|
|
iconProps: {
|
|
name: "gear-wide-connected",
|
|
color: this.colors.crawls,
|
|
},
|
|
})}
|
|
${this.renderStat({
|
|
value: metrics.uploadCount,
|
|
secondaryValue: hasQuota
|
|
? ""
|
|
: html`<sl-format-bytes
|
|
value=${metrics.storageUsedUploads}
|
|
></sl-format-bytes>`,
|
|
singleLabel: msg("Upload"),
|
|
pluralLabel: msg("Uploads"),
|
|
iconProps: { name: "upload", color: this.colors.uploads },
|
|
})}
|
|
${this.renderStat({
|
|
value: metrics.profileCount,
|
|
secondaryValue: hasQuota
|
|
? ""
|
|
: html`<sl-format-bytes
|
|
value=${metrics.storageUsedProfiles}
|
|
></sl-format-bytes>`,
|
|
singleLabel: msg("Browser Profile"),
|
|
pluralLabel: msg("Browser Profiles"),
|
|
iconProps: {
|
|
name: "window-fullscreen",
|
|
color: this.colors.browserProfiles,
|
|
},
|
|
})}
|
|
<sl-divider
|
|
style="--spacing:var(--sl-spacing-small)"
|
|
></sl-divider>
|
|
${this.renderStat({
|
|
value: metrics.archivedItemCount,
|
|
secondaryValue: hasQuota
|
|
? ""
|
|
: html`<sl-format-bytes
|
|
value=${metrics.storageUsedBytes}
|
|
></sl-format-bytes>`,
|
|
singleLabel: msg("Archived Item"),
|
|
pluralLabel: msg("Archived Items"),
|
|
iconProps: { name: "file-zip-fill" },
|
|
})}
|
|
</dl>
|
|
`,
|
|
)}
|
|
${this.renderCard(
|
|
msg("Crawling"),
|
|
(metrics) => html`
|
|
${this.renderCrawlingMeter(metrics)}
|
|
<dl>
|
|
${this.renderStat({
|
|
value:
|
|
metrics.workflowsRunningCount && metrics.maxConcurrentCrawls
|
|
? `${metrics.workflowsRunningCount} / ${metrics.maxConcurrentCrawls}`
|
|
: metrics.workflowsRunningCount,
|
|
singleLabel: msg("Crawl Running"),
|
|
pluralLabel: msg("Crawls Running"),
|
|
iconProps: {
|
|
name: "dot",
|
|
library: "app",
|
|
color: metrics.workflowsRunningCount ? "green" : "neutral",
|
|
},
|
|
})}
|
|
${this.renderStat({
|
|
value: metrics.workflowsQueuedCount,
|
|
singleLabel: msg("Crawl Workflow Waiting"),
|
|
pluralLabel: msg("Crawl Workflows Waiting"),
|
|
iconProps: { name: "hourglass-split", color: "purple" },
|
|
})}
|
|
${this.renderStat({
|
|
value: metrics.pageCount,
|
|
singleLabel: msg("Page Crawled"),
|
|
pluralLabel: msg("Pages Crawled"),
|
|
iconProps: { name: "file-richtext-fill" },
|
|
})}
|
|
</dl>
|
|
`,
|
|
)}
|
|
${this.renderCard(
|
|
msg("Collections"),
|
|
(metrics) => html`
|
|
<dl>
|
|
${this.renderStat({
|
|
value: metrics.collectionsCount,
|
|
singleLabel: msg("Collection Total"),
|
|
pluralLabel: msg("Collections Total"),
|
|
iconProps: { name: "collection-fill" },
|
|
})}
|
|
${this.renderStat({
|
|
value: metrics.publicCollectionsCount,
|
|
singleLabel: msg("Shareable Collection"),
|
|
pluralLabel: msg("Shareable Collections"),
|
|
iconProps: { name: "people-fill", color: "emerald" },
|
|
})}
|
|
</dl>
|
|
`,
|
|
)}
|
|
</div>
|
|
${when(
|
|
this.appState.settings &&
|
|
!this.appState.settings.billingEnabled &&
|
|
this.org,
|
|
(org) => html`
|
|
<section class="mb-10">
|
|
<btrix-details open>
|
|
<span slot="title">${msg("Usage History")}</span>
|
|
<btrix-usage-history-table
|
|
.org=${org}
|
|
></btrix-usage-history-table>
|
|
</btrix-details>
|
|
</section>
|
|
`,
|
|
)}
|
|
</main> `;
|
|
}
|
|
|
|
private renderStorageMeter(metrics: Metrics) {
|
|
const hasQuota = Boolean(metrics.storageQuotaBytes);
|
|
const isStorageFull =
|
|
hasQuota && metrics.storageUsedBytes >= metrics.storageQuotaBytes;
|
|
const renderBar = (value: number, label: string, color: string) => html`
|
|
<btrix-meter-bar
|
|
value=${(value / metrics.storageUsedBytes) * 100}
|
|
style="--background-color:var(--sl-color-${color}-400)"
|
|
>
|
|
<div class="text-center">
|
|
<div>${label}</div>
|
|
<div class="text-xs opacity-80">
|
|
<sl-format-bytes value=${value} display="narrow"></sl-format-bytes>
|
|
| ${this.renderPercentage(value / metrics.storageUsedBytes)}
|
|
</div>
|
|
</div>
|
|
</btrix-meter-bar>
|
|
`;
|
|
return html`
|
|
<div class="mb-1 font-semibold">
|
|
${when(
|
|
isStorageFull,
|
|
() => html`
|
|
<div class="flex items-center gap-2">
|
|
<sl-icon
|
|
class="text-danger"
|
|
name="exclamation-triangle"
|
|
></sl-icon>
|
|
<span>${msg("Storage is Full")}</span>
|
|
</div>
|
|
`,
|
|
() =>
|
|
hasQuota
|
|
? html`
|
|
<sl-format-bytes
|
|
value=${metrics.storageQuotaBytes -
|
|
metrics.storageUsedBytes}
|
|
></sl-format-bytes>
|
|
${msg("Available")}
|
|
`
|
|
: "",
|
|
)}
|
|
</div>
|
|
${when(
|
|
hasQuota,
|
|
() => html`
|
|
<div class="mb-2">
|
|
<btrix-meter
|
|
value=${metrics.storageUsedBytes}
|
|
max=${ifDefined(metrics.storageQuotaBytes || undefined)}
|
|
valueText=${msg("gigabyte")}
|
|
>
|
|
${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,
|
|
),
|
|
)}
|
|
<div slot="available" class="flex-1">
|
|
<sl-tooltip class="text-center">
|
|
<div slot="content">
|
|
<div>${msg("Available")}</div>
|
|
<div class="text-xs opacity-80">
|
|
${this.renderPercentage(
|
|
(metrics.storageQuotaBytes - metrics.storageUsedBytes) /
|
|
metrics.storageQuotaBytes,
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div class="h-full w-full"></div>
|
|
</sl-tooltip>
|
|
</div>
|
|
<sl-format-bytes
|
|
slot="valueLabel"
|
|
value=${metrics.storageUsedBytes}
|
|
display="narrow"
|
|
></sl-format-bytes>
|
|
<sl-format-bytes
|
|
slot="maxLabel"
|
|
value=${metrics.storageQuotaBytes}
|
|
display="narrow"
|
|
></sl-format-bytes>
|
|
</btrix-meter>
|
|
</div>
|
|
`,
|
|
)}
|
|
`;
|
|
}
|
|
|
|
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}` as YearMonth;
|
|
|
|
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` <btrix-divided-meter-bar
|
|
value=${(used / quotaSecondsAllTypes) * 100}
|
|
quota=${(quota / quotaSecondsAllTypes) * 100}
|
|
style="--background-color:var(--sl-color-${color}-400); --quota-background-color:var(--sl-color-${color}-100)"
|
|
>
|
|
<div class="text-center">
|
|
<div>${label}</div>
|
|
<div class="text-xs opacity-80">
|
|
${humanizeExecutionSeconds(used, { displaySeconds: true })} /
|
|
${humanizeExecutionSeconds(quota, { displaySeconds: true })}
|
|
</div>
|
|
</div>
|
|
</btrix-divided-meter-bar>`;
|
|
} else {
|
|
return html`<btrix-meter-bar
|
|
value=${100}
|
|
style="--background-color:var(--sl-color-${color}-400);"
|
|
>
|
|
<div class="text-center">
|
|
<div>${label}</div>
|
|
<div class="text-xs opacity-80">
|
|
${humanizeExecutionSeconds(used, { displaySeconds: true })} |
|
|
${this.renderPercentage(used / quota)}
|
|
</div>
|
|
</div>
|
|
</btrix-meter-bar>`;
|
|
}
|
|
};
|
|
return html`
|
|
<div class="mb-1 font-semibold">
|
|
${when(
|
|
isReached,
|
|
() => html`
|
|
<div class="flex items-center gap-2">
|
|
<sl-icon
|
|
class="text-danger"
|
|
name="exclamation-triangle"
|
|
></sl-icon>
|
|
<span>${msg("Execution Minutes Quota Reached")}</span>
|
|
</div>
|
|
`,
|
|
() =>
|
|
hasQuota && this.org
|
|
? html`
|
|
<span class="inline-flex items-center">
|
|
${humanizeExecutionSeconds(
|
|
quotaSeconds -
|
|
usageSeconds +
|
|
this.org.extraExecSecondsAvailable +
|
|
this.org.giftedExecSecondsAvailable,
|
|
{ style: "short", round: "down" },
|
|
)}
|
|
<span class="ml-1">${msg("remaining")}</span>
|
|
</span>
|
|
`
|
|
: "",
|
|
)}
|
|
</div>
|
|
${when(
|
|
hasQuota && this.org,
|
|
(org) => html`
|
|
<div class="mb-2">
|
|
<btrix-meter
|
|
value=${org.giftedExecSecondsAvailable ||
|
|
org.extraExecSecondsAvailable ||
|
|
isReached
|
|
? quotaSecondsAllTypes
|
|
: usageSeconds}
|
|
max=${quotaSecondsAllTypes}
|
|
valueText=${msg("time")}
|
|
>
|
|
${when(usageSeconds || quotaSeconds, () =>
|
|
renderBar(
|
|
usageSeconds > quotaSeconds ? quotaSeconds : usageSeconds,
|
|
hasExtra ? quotaSeconds : quotaSecondsAllTypes,
|
|
msg("Monthly Execution Time Used"),
|
|
"green",
|
|
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"),
|
|
"red",
|
|
),
|
|
)}
|
|
<div slot="available" class="flex-1">
|
|
<sl-tooltip class="text-center">
|
|
<div slot="content">
|
|
<div>${msg("Monthly Execution Time Remaining")}</div>
|
|
<div class="text-xs opacity-80">
|
|
${humanizeExecutionSeconds(quotaSeconds - usageSeconds, {
|
|
displaySeconds: true,
|
|
})}
|
|
|
|
|
${this.renderPercentage(
|
|
(quotaSeconds - usageSeconds) / quotaSeconds,
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div class="h-full w-full"></div>
|
|
</sl-tooltip>
|
|
</div>
|
|
<span slot="valueLabel">
|
|
${humanizeExecutionSeconds(usageSecondsAllTypes, {
|
|
style: "short",
|
|
})}
|
|
</span>
|
|
<span slot="maxLabel">
|
|
${humanizeExecutionSeconds(quotaSecondsAllTypes, {
|
|
style: "short",
|
|
})}
|
|
</span>
|
|
</btrix-meter>
|
|
</div>
|
|
`,
|
|
)}
|
|
`;
|
|
}
|
|
|
|
private renderCard(
|
|
title: string,
|
|
renderContent: (metric: Metrics) => TemplateResult,
|
|
renderFooter?: (metric: Metrics) => TemplateResult,
|
|
) {
|
|
return html`
|
|
<btrix-card class="flex-1">
|
|
<span slot="title">${title}</span>
|
|
${when(
|
|
this.metrics,
|
|
() => renderContent(this.metrics!),
|
|
this.renderCardSkeleton,
|
|
)}
|
|
${when(
|
|
renderFooter && this.metrics,
|
|
() => html`<div slot="footer">${renderFooter!(this.metrics!)}</div>`,
|
|
)}
|
|
</btrix-card>
|
|
`;
|
|
}
|
|
|
|
private renderStat(stat: {
|
|
value: number | string | TemplateResult;
|
|
secondaryValue?: number | string | TemplateResult;
|
|
singleLabel: string;
|
|
pluralLabel: string;
|
|
iconProps: { name: string; library?: string; color?: string };
|
|
}) {
|
|
const { value, iconProps } = stat;
|
|
return html`
|
|
<div class="mb-2 flex items-center justify-between last:mb-0">
|
|
<div class="flex items-center">
|
|
<sl-icon
|
|
class="mr-2 text-base text-neutral-500"
|
|
name=${iconProps.name}
|
|
library=${ifDefined(iconProps.library)}
|
|
style="color:var(--sl-color-${iconProps.color ||
|
|
this.colors.default}-500)"
|
|
></sl-icon>
|
|
<dt class="order-last">
|
|
${value === 1 ? stat.singleLabel : stat.pluralLabel}
|
|
</dt>
|
|
<dd class="mr-1">
|
|
${typeof value === "number" ? value.toLocaleString() : value}
|
|
</dd>
|
|
</div>
|
|
${when(
|
|
stat.secondaryValue,
|
|
() => html`
|
|
<div class="font-monostyle text-xs text-neutral-500">
|
|
${stat.secondaryValue}
|
|
</div>
|
|
`,
|
|
)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private readonly renderCardSkeleton = () => html`
|
|
<sl-skeleton class="mb-3" effect="sheen"></sl-skeleton>
|
|
<sl-skeleton class="mb-3" effect="sheen"></sl-skeleton>
|
|
<sl-skeleton class="mb-3" effect="sheen"></sl-skeleton>
|
|
<sl-skeleton class="mb-3" effect="sheen"></sl-skeleton>
|
|
`;
|
|
|
|
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.apiFetch<Metrics | undefined>(
|
|
`/orgs/${this.orgId}/metrics`,
|
|
this.authState!,
|
|
);
|
|
|
|
this.metrics = data;
|
|
} catch (e) {
|
|
this.notify({
|
|
message: msg("Sorry, couldn't retrieve org metrics at this time."),
|
|
variant: "danger",
|
|
icon: "exclamation-octagon",
|
|
});
|
|
}
|
|
}
|
|
}
|