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) { 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`

${this.org?.name}

${when( this.isAdmin, () => html` `, )} ${when( this.isCrawler, () => html` { this.dispatchEvent( new CustomEvent("select-new-dialog", { detail: e.detail.item.value, }) as SelectNewDialogEvent, ); }} > ${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" }, })}
`, )}
${when( this.appState.settings && !this.appState.settings.billingEnabled && this.org, (org) => html`
${msg("Usage History")}
`, )}
`; } 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) { 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`
${label}
${humanizeExecutionSeconds(used, { displaySeconds: true })} / ${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"), "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", ), )}
${msg("Monthly Execution Time Remaining")}
${humanizeExecutionSeconds(quotaSeconds - usageSeconds, { displaySeconds: true, })} | ${this.renderPercentage( (quotaSeconds - usageSeconds) / quotaSeconds, )}
${humanizeExecutionSeconds(usageSecondsAllTypes, { style: "short", })} ${humanizeExecutionSeconds(quotaSecondsAllTypes, { style: "short", })}
`, )} `; } 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, () => html`
${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 readonly renderCardSkeleton = () => html` `; 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) { this.notify({ message: msg("Sorry, couldn't retrieve org metrics at this time."), variant: "danger", icon: "exclamation-octagon", }); } } }