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) { 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` ${compactFormatter.format(minutes)} (${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`

${this.org?.name}

{ this.dispatchEvent( new CustomEvent("select-new-dialog", { detail: e.detail.item.value, }) ); }} > ${msg("Create New...")} ${msg("Crawl Workflow")} ${msg("Upload")} ${msg("Collection")} ${msg("Browser Profile")}
${this.renderCard( msg("Storage"), (metrics) => html` ${this.renderStorageMeter(metrics)}
${this.renderStat({ value: metrics.crawlCount, secondaryValue: hasQuota ? "" : html``, singleLabel: msg("Crawl"), pluralLabel: msg("Crawls"), iconProps: { name: "gear-wide-connected", color: this.colors.crawls, }, })} ${this.renderStat({ value: metrics.uploadCount, secondaryValue: hasQuota ? "" : html``, singleLabel: msg("Upload"), pluralLabel: msg("Uploads"), iconProps: { name: "upload", color: this.colors.uploads }, })} ${this.renderStat({ value: metrics.profileCount, secondaryValue: hasQuota ? "" : html``, singleLabel: msg("Browser Profile"), pluralLabel: msg("Browser Profiles"), iconProps: { name: "window-fullscreen", color: this.colors.browserProfiles, }, })} ${this.renderStat({ value: metrics.archivedItemCount, secondaryValue: hasQuota ? "" : html``, singleLabel: msg("Archived Item"), pluralLabel: msg("Archived Items"), iconProps: { name: "file-zip-fill" }, })}
` )} ${this.renderCard( msg("Crawling"), (metrics) => html` ${this.renderCrawlingMeter(metrics)}
${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" }, })}
` )} ${this.renderCard( msg("Collections"), (metrics) => html`
${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" }, })}
` )}
${this.renderUsageHistory()}
`; } 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`
${label}
| ${this.renderPercentage(value / metrics.storageUsedBytes)}
`; return html`
${when( isStorageFull, () => html`
${msg("Storage is Full")}
`, () => hasQuota ? html` ${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 ) )}
${msg("Available")}
${this.renderPercentage( (metrics.storageQuotaBytes - metrics.storageUsedBytes) / metrics.storageQuotaBytes )}
` )} `; } 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`
${label}
${this.humanizeExecutionSeconds(value)} | ${this.renderPercentage(value / quotaSeconds)}
`; return html`
${when( isReached, () => html`
${msg("Monthly Execution Minutes Quota Reached")}
`, () => hasQuota ? html` ${this.humanizeExecutionSeconds( quotaSeconds - usageSeconds )} ${msg("Available")} ` : "" )}
${when( hasQuota, () => html`
${when(usageSeconds, () => renderBar( usageSeconds, msg("Monthly Execution Time Used"), isReached ? "warning" : this.colors.runningTime ) )}
${msg("Monthly Execution Time Available")}
${this.humanizeExecutionSeconds( quotaSeconds - usageSeconds )} | ${this.renderPercentage( (quotaSeconds - usageSeconds) / quotaSeconds )}
${this.humanizeExecutionSeconds(usageSeconds)} ${this.humanizeExecutionSeconds(quotaSeconds)}
` )} `; } private renderCard( title: string, renderContent: (metric: Metrics) => TemplateResult, renderFooter?: (metric: Metrics) => TemplateResult ) { return html`

${title}

${when( this.metrics, () => renderContent(this.metrics!), this.renderCardSkeleton )}
${when(renderFooter && this.metrics, () => renderFooter!(this.metrics!) )}
`; } 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`
${value === 1 ? stat.singleLabel : stat.pluralLabel}
${typeof value === "number" ? value.toLocaleString() : value}
${when( stat.secondaryValue, () => html`
${stat.secondaryValue}
` )}
`; } private renderCardSkeleton = () => html` `; // TODO fix style when data-table is converted to slots readonly usageTableCols = [ msg("Month"), html` ${msg("Execution Time")}
${msg("Total running time of all crawler instances")}
`, html` ${msg("Elapsed Time")}
${msg("Total time elapsed between when crawls started and ended")}
`, ]; 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` `, value ? this.humanizeExecutionSeconds(value) : "--", this.humanizeExecutionSeconds(crawlTime || 0), ]; }); return html` ${msg("Usage History")}
`; } 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);