import { localized, msg } from "@lit/localize"; import { Task, TaskStatus } from "@lit/task"; import type { SlChangeEvent, SlRadioGroup, SlSelectEvent, } from "@shoelace-style/shoelace"; import clsx from "clsx"; import { html, nothing, type PropertyValues, type 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 queryString from "query-string"; 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 { 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 { 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; 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", } const PAGE_SIZE = 16; @customElement("btrix-dashboard") @localized() export class Dashboard extends BtrixElement { @property({ type: Boolean }) isCrawler?: boolean; @state() private metrics?: Metrics; @state() collectionRefreshing: string | null = null; @state() collectionsView = CollectionGridView.Public; @state() collectionPage = parsePage(new URLSearchParams(location.search).get("page")); // Used for busting cache when updating visible collection cacheBust = 0; private readonly colors = { default: tw`text-neutral-600`, crawls: tw`text-green-600`, uploads: tw`text-sky-600`, browserProfiles: tw`text-indigo-600`, runningTime: tw`text-blue-600`, }; private readonly collections = new Task(this, { task: cached( async ([orgId, collectionsView, collectionPage]) => { if (!orgId) throw new Error("orgId required"); const collections = await this.getCollections({ orgId, access: collectionsView === CollectionGridView.Public ? CollectionAccess.Public : undefined, page: collectionPage, }); this.collectionRefreshing = null; return collections; }, { cacheConstructor: timeoutCache(300) }, ), args: () => [ this.orgId, this.collectionsView, this.collectionPage, this.cacheBust, ] as const, }); willUpdate(changedProperties: PropertyValues & Map) { if (changedProperties.has("appState.orgSlug") && this.orgId) { void this.fetchMetrics(); } } firstUpdated() { if (this.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` ${pageHeader({ title: this.userOrg?.name, secondary: html` ${when( this.org?.publicDescription, (publicDescription) => html`
${richText(publicDescription)}
`, )} ${when(this.org?.publicUrl, (urlStr) => { let url: URL; try { url = new URL(urlStr); } catch { return nothing; } return html` `; })} `, actions: html` ${when( this.appState.isAdmin, () => html` `, )} ${when( this.isCrawler, () => html` { const { value } = e.detail.item; if (value === "workflow") { this.navigate.to( `${this.navigate.orgBasePath}/workflows/new`, ); } else { 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")} `, )} `, classNames: tw`border-b-transparent lg:mb-2`, })}
${this.renderCard( msg("Storage"), (metrics) => html` ${this.renderStorageMeter(metrics)}
${this.renderStat({ value: metrics.crawlCount, secondaryValue: hasQuota ? "" : this.localize.bytes(metrics.storageUsedCrawls), singleLabel: msg("Crawl"), pluralLabel: msg("Crawls"), iconProps: { name: "gear-wide-connected", class: this.colors.crawls, }, })} ${this.renderStat({ value: metrics.uploadCount, secondaryValue: hasQuota ? "" : this.localize.bytes(metrics.storageUsedUploads), singleLabel: msg("Upload"), pluralLabel: msg("Uploads"), iconProps: { name: "upload", class: this.colors.uploads }, })} ${this.renderStat({ value: metrics.profileCount, secondaryValue: hasQuota ? "" : this.localize.bytes(metrics.storageUsedProfiles), singleLabel: msg("Browser Profile"), pluralLabel: msg("Browser Profiles"), iconProps: { name: "window-fullscreen", class: this.colors.browserProfiles, }, })} ${this.renderStat({ value: metrics.archivedItemCount, secondaryValue: hasQuota ? "" : this.localize.bytes(metrics.storageUsedBytes), 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", class: metrics.workflowsRunningCount ? tw`animate-pulse text-green-600` : tw`text-neutral-600`, }, })} ${this.renderStat({ value: metrics.workflowsQueuedCount, singleLabel: msg("Crawl Workflow Waiting"), pluralLabel: msg("Crawl Workflows Waiting"), iconProps: { name: "hourglass-split", class: tw`text-violet-600`, }, })} ${this.renderStat({ value: metrics.crawlPageCount, singleLabel: msg("Page Crawled"), pluralLabel: msg("Pages Crawled"), iconProps: { name: "file-richtext-fill", class: this.colors.crawls, }, })} ${this.renderStat({ value: metrics.uploadPageCount, singleLabel: msg("Page Uploaded"), pluralLabel: msg("Pages Uploaded"), iconProps: { name: "file-richtext-fill", class: this.colors.uploads, }, })} ${this.renderStat({ value: metrics.pageCount, singleLabel: msg("Page Total"), pluralLabel: msg("Pages Total"), 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", class: tw`text-emerald-600`, }, })}
`, )}
${pageHeading({ content: this.collectionsView === CollectionGridView.Public ? msg("Public Collections") : msg("All Collections"), })} ${ this.collectionsView === CollectionGridView.Public ? html` ${this.org?.enablePublicProfile ? msg("Visit public collections gallery") : msg("Preview public collections gallery")} ${this.org?.enablePublicProfile ? html`` : nothing} ` : nothing }
${when( this.appState.isCrawler, () => html` `, )} { this.collectionPage = 1; this.collectionsView = (e.target as SlRadioGroup) .value as CollectionGridView; }} >
{ this.collectionRefreshing = e.detail.id; void this.collections.run([ this.orgId, this.collectionsView, this.collectionPage, ++this.cacheBust, ]); }} > ${this.renderNoPublicCollections()} ${ this.collectionsView === CollectionGridView.Public ? msg("No public collections yet.") : msg("No collections yet.") } ${ this.collections.value && this.collections.value.total > this.collections.value.items.length ? html` { this.collectionPage = e.detail.page; }} slot="pagination" > ` : nothing } ${ this.collections.status === TaskStatus.PENDING && this.collections.value ? html`
` : nothing }
`; } private renderNoPublicCollections() { if (!this.org || !this.metrics) return; let button: TemplateResult; if (this.metrics.collectionsCount) { button = html` { this.navigate.to(`${this.navigate.orgBasePath}/collections`); }} > ${msg("Manage Collections")} `; } else { button = html` { this.dispatchEvent( new CustomEvent("select-new-dialog", { detail: "collection", }) as SelectNewDialogEvent, ); }} > ${msg("Create a Collection")} `; } return html`
${button}
`; } 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.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, ), )}
${msg("Available")}
${this.renderPercentage( (metrics.storageQuotaBytes - metrics.storageUsedBytes) / metrics.storageQuotaBytes, )}
${this.localize.bytes(metrics.storageUsedBytes, { unitDisplay: "narrow", })} ${this.localize.bytes(metrics.storageQuotaBytes, { unitDisplay: "narrow", })}
`, )} `; } 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`
${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; class?: string }; }) { const { value, iconProps } = stat; return html`
${value === 1 ? stat.singleLabel : stat.pluralLabel}
${typeof value === "number" ? this.localize.number(value) : 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.api.fetch( `/orgs/${this.orgId}/metrics`, ); this.metrics = data; } catch (e) { this.notify.toast({ message: msg("Sorry, couldn't retrieve org metrics at this time."), variant: "danger", icon: "exclamation-octagon", id: "metrics-retrieve-error", }); } } private async getCollections({ orgId, access, page, }: { orgId: string; access?: CollectionAccess; page?: number; }) { const params: APISortQuery & { access?: CollectionAccess; page?: number; pageSize?: number; } = { sortBy: "dateLatest", sortDirection: SortDirection.Descending, access, page, pageSize: PAGE_SIZE, }; const query = queryString.stringify(params); const data = await this.api.fetch>( `/orgs/${orgId}/collections?${query}`, ); return data; } }