browsertrix/frontend/src/pages/org/dashboard.ts

633 lines
20 KiB
TypeScript

import type { PropertyValues, TemplateResult } from "lit";
import { state, property } from "lit/decorators.js";
import { when } from "lit/directives/when.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { msg, localized, str } from "@lit/localize";
import type { SlSelectEvent } from "@shoelace-style/shoelace";
import humanizeDuration from "pretty-ms";
import LiteElement, { html } from "../../utils/LiteElement";
import type { AuthState } from "../../utils/AuthService";
import type { OrgData } from "../../utils/orgs";
import type { SelectNewDialogEvent } from "./index";
import { getLocale } from "../../utils/localization";
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;
};
const BYTES_PER_GB = 1e9;
@localized()
export class Dashboard extends LiteElement {
@property({ type: Object })
authState!: AuthState;
@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")) {
this.fetchMetrics();
}
}
private humanizeExecutionSeconds = (seconds: number) => {
const minutes = Math.ceil(seconds / 60);
const locale = getLocale();
const compactFormatter = new Intl.NumberFormat(locale, {
notation: "compact",
style: "unit",
unit: "minute",
unitDisplay: "long",
});
const fullFormatter = new Intl.NumberFormat(locale, {
style: "unit",
unit: "minute",
unitDisplay: "long",
maximumFractionDigits: 0,
});
return html`<span title="${fullFormatter.format(minutes)}">
${compactFormatter.format(minutes)}</span
>
(${humanizeDuration(seconds * 1000)})`;
};
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="flex items-center justify-end gap-2 pb-3 mb-7 border-b"
>
<h1 class="min-w-0 text-xl font-semibold leading-8 mr-auto">
${this.org?.name}
</h1>
<sl-icon-button
href=${`${this.orgBasePath}/settings`}
class="text-lg"
name="gear"
label="Edit org settings"
@click=${this.navLink}
></sl-icon-button>
<sl-dropdown
distance="4"
placement="bottom-end"
@sl-select=${(e: SlSelectEvent) => {
this.dispatchEvent(
<SelectNewDialogEvent>new CustomEvent("select-new-dialog", {
detail: e.detail.item.value,
})
);
}}
>
<sl-button slot="trigger" size="small" caret>
<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="flex flex-col md:flex-row gap-6">
${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>
<section class="mt-10">${this.renderUsageHistory()}</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="font-semibold mb-1">
${when(
isStorageFull,
() => html`
<div class="flex gap-2 items-center">
<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>
<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="w-full h-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) {
let quotaSeconds = 0;
if (this.org!.quotas && this.org!.quotas.maxExecMinutesPerMonth) {
quotaSeconds = this.org!.quotas.maxExecMinutesPerMonth * 60;
}
let usageSeconds = 0;
const now = new Date();
if (this.org!.crawlExecSeconds) {
const actualUsage =
this.org!.crawlExecSeconds[
`${now.getFullYear()}-${now.getUTCMonth() + 1}`
];
if (actualUsage) {
usageSeconds = actualUsage;
}
}
const hasQuota = Boolean(quotaSeconds);
const isReached = hasQuota && usageSeconds >= quotaSeconds;
if (isReached) {
usageSeconds = quotaSeconds;
}
const renderBar = (
/** Time in Seconds */
value: number,
label: string,
color: string
) => html`
<btrix-meter-bar
value=${(value / usageSeconds) * 100}
style="--background-color:var(--sl-color-${color}-400)"
>
<div class="text-center">
<div>${label}</div>
<div class="text-xs opacity-80">
${this.humanizeExecutionSeconds(value)} |
${this.renderPercentage(value / quotaSeconds)}
</div>
</div>
</btrix-meter-bar>
`;
return html`
<div class="font-semibold mb-1">
${when(
isReached,
() => html`
<div class="flex gap-2 items-center">
<sl-icon
class="text-danger"
name="exclamation-triangle"
></sl-icon>
<span>${msg("Monthly Execution Minutes Quota Reached")}</span>
</div>
`,
() =>
hasQuota
? html`
<span class="inline-flex items-center">
${this.humanizeExecutionSeconds(
quotaSeconds - usageSeconds
)}
${msg("Available")}
</span>
`
: ""
)}
</div>
${when(
hasQuota,
() => html`
<div class="mb-2">
<btrix-meter
value=${isReached ? quotaSeconds : usageSeconds}
max=${ifDefined(quotaSeconds || undefined)}
valueText=${msg("time")}
>
${when(usageSeconds, () =>
renderBar(
usageSeconds,
msg("Monthly Execution Time Used"),
isReached ? "warning" : this.colors.runningTime
)
)}
<div slot="available" class="flex-1">
<sl-tooltip>
<div slot="content">
<div>${msg("Monthly Execution Time Available")}</div>
<div class="text-xs opacity-80">
${this.humanizeExecutionSeconds(
quotaSeconds - usageSeconds
)}
|
${this.renderPercentage(
(quotaSeconds - usageSeconds) / quotaSeconds
)}
</div>
</div>
<div class="w-full h-full"></div>
</sl-tooltip>
</div>
<span slot="valueLabel">
${this.humanizeExecutionSeconds(usageSeconds)}
</span>
<span slot="maxLabel">
${this.humanizeExecutionSeconds(quotaSeconds)}
</span>
</btrix-meter>
</div>
`
)}
`;
}
private renderCard(
title: string,
renderContent: (metric: Metrics) => TemplateResult,
renderFooter?: (metric: Metrics) => TemplateResult
) {
return html`
<section class="flex-1 flex flex-col border rounded p-4">
<h2 class="text-lg font-semibold leading-none border-b pb-3 mb-3">
${title}
</h2>
<div class="flex-1">
${when(
this.metrics,
() => renderContent(this.metrics!),
this.renderCardSkeleton
)}
</div>
${when(renderFooter && this.metrics, () =>
renderFooter!(this.metrics!)
)}
</section>
`;
}
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="flex items-center justify-between mb-2 last:mb-0">
<div class="flex items-center">
<sl-icon
class="text-base text-neutral-500 mr-2"
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="text-xs text-neutral-500 font-monostyle">
${stat.secondaryValue}
</div>
`
)}
</div>
`;
}
private 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>
`;
// TODO fix style when data-table is converted to slots
readonly usageTableCols = [
msg("Month"),
html`
${msg("Execution Time")}
<sl-tooltip>
<div slot="content" style="text-transform: initial">
${msg("Total running time of all crawler instances")}
</div>
<sl-icon name="info-circle" style="vertical-align: -.175em"></sl-icon>
</sl-tooltip>
`,
html`
${msg("Elapsed Time")}
<sl-tooltip>
<div slot="content" style="text-transform: initial">
${msg("Total time elapsed between when crawls started and ended")}
</div>
<sl-icon name="info-circle" style="vertical-align: -.175em"></sl-icon>
</sl-tooltip>
`,
];
private renderUsageHistory() {
if (!this.org) return;
const rows = Object.entries(this.org.usage || {})
// Sort latest
.reverse()
.map(([mY, crawlTime]) => {
const value = this.org!.crawlExecSeconds?.[mY];
return [
html`
<sl-format-date
date="${mY}-01T00:00:00.000Z"
time-zone="utc"
month="long"
year="numeric"
>
</sl-format-date>
`,
value ? this.humanizeExecutionSeconds(value) : "--",
this.humanizeExecutionSeconds(crawlTime || 0),
];
});
return html`
<btrix-details>
<span slot="title">${msg("Usage History")}</span>
<div class="border rounded overflow-hidden">
<btrix-data-table
.columns=${this.usageTableCols}
.rows=${rows}
></btrix-data-table>
</div>
</btrix-details>
`;
}
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(
`/orgs/${this.orgId}/metrics`,
this.authState!
);
this.metrics = data;
} catch (e: any) {
this.notify({
message: msg("Sorry, couldn't retrieve org metrics at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
}
customElements.define("btrix-dashboard", Dashboard);