diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index da3620e5..bd629d85 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -153,6 +153,10 @@ import("./code").then(({ Code }) => { import("./search-combobox").then(({ SearchCombobox }) => { customElements.define("btrix-search-combobox", SearchCombobox); }); +import("./meter").then(({ Meter, MeterBar }) => { + customElements.define("btrix-meter", Meter); + customElements.define("btrix-meter-bar", MeterBar); +}); customElements.define("btrix-alert", Alert); customElements.define("btrix-input", Input); customElements.define("btrix-time-input", TimeInput); diff --git a/frontend/src/components/meter.ts b/frontend/src/components/meter.ts new file mode 100644 index 00000000..cbec3d43 --- /dev/null +++ b/frontend/src/components/meter.ts @@ -0,0 +1,201 @@ +import { LitElement, html, css, PropertyValues } from "lit"; +import { + property, + query, + queryAssignedElements, + state, +} from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import debounce from "lodash/fp/debounce"; + +export class MeterBar extends LitElement { + /* Percentage of value / max */ + @property({ type: Number }) + value = 0; + + static styles = css` + :host { + display: contents; + } + + .bar { + height: 1rem; + background-color: var(--background-color, var(--sl-color-blue-500)); + min-width: 4px; + border-right: var(--border-right, 0); + } + `; + + render() { + if (this.value <= 0) { + return; + } + return html` + + + `; + } +} + +/** + * Show scalar value within a range + * + * Usage example: + * ```ts + * + * ``` + */ +export class Meter extends LitElement { + @property({ type: Number }) + min = 0; + + @property({ type: Number }) + max = 100; + + @property({ type: Number }) + value = 0; + + @property({ type: Array }) + subValues?: number[]; + + @property({ type: String }) + valueText?: string; + + @query(".valueBar") + private valueBar?: HTMLElement; + + @query(".labels") + private labels?: HTMLElement; + + @query(".maxText") + private maxText?: HTMLElement; + + static styles = css` + .meter { + position: relative; + } + + .track { + display: flex; + height: 1rem; + border-radius: var(--sl-border-radius-medium); + background-color: var(--sl-color-neutral-100); + box-shadow: inset 0px 1px 1px 0px rgba(0, 0, 0, 0.25); + } + + .valueBar { + display: flex; + border-radius: var(--sl-border-radius-medium); + overflow: hidden; + } + + .labels { + display: flex; + text-align: right; + white-space: nowrap; + color: var(--sl-color-neutral-500); + font-size: var(--sl-font-size-x-small); + font-family: var(--font-monostyle-family); + font-variation-settings: var(--font-monostyle-variation); + line-height: 1; + margin-top: var(--sl-spacing-x-small); + } + + .label.max { + flex-grow: 1; + } + + .valueText { + display: inline-flex; + } + + .valueText.withSeparator:after { + content: "/"; + padding: 0 0.3ch; + } + + .maxText { + display: inline-block; + } + `; + + @queryAssignedElements({ selector: "btrix-meter-bar" }) + bars?: Array; + + updated(changedProperties: PropertyValues) { + if (changedProperties.has("value") || changedProperties.has("max")) { + this.repositionLabels(); + } + } + + render() { + // meter spec disallow values that exceed max + const boundedValue = Math.max(Math.min(this.value, this.max), this.min); + const barWidth = `${(boundedValue / this.max) * 100}%`; + return html` + + + + + + + ${this.value < this.max ? html`` : ""} + + + + + + ${this.value} + + + + + ${this.max} + + + + + `; + } + + private onTrackResize = debounce(100)((e: CustomEvent) => { + const { entries } = e.detail; + const entry = entries[0]; + const trackWidth = entry.contentBoxSize[0].inlineSize; + this.repositionLabels(trackWidth); + }) as any; + + private repositionLabels(trackWidth?: number) { + if (!this.valueBar || !this.maxText) return; + const trackW = trackWidth || this.valueBar.closest(".track")?.clientWidth; + if (!trackW) return; + const barWidth = this.valueBar.clientWidth; + const pad = 8; + const remaining = Math.ceil(trackW - barWidth - pad); + + // Show compact value/max label when almost touching + const valueText = this.labels?.querySelector(".valueText"); + if (this.maxText && this.maxText.clientWidth >= remaining) { + valueText?.classList.add("withSeparator"); + } else { + valueText?.classList.remove("withSeparator"); + } + } + + private handleSlotchange() { + if (!this.bars) return; + this.bars.forEach((el, i, arr) => { + if (i < arr.length - 1) { + el.style.cssText += + "--border-right: 1px solid var(--sl-color-neutral-600)"; + } + }); + } +} diff --git a/frontend/src/pages/org/dashboard.ts b/frontend/src/pages/org/dashboard.ts index 2bd1fa35..d28430a4 100644 --- a/frontend/src/pages/org/dashboard.ts +++ b/frontend/src/pages/org/dashboard.ts @@ -12,9 +12,10 @@ import type { SelectNewDialogEvent } from "./index"; type Metrics = { storageUsedBytes: number; - storageUsedGB: number; + storageUsedCrawls: number; + storageUsedUploads: number; + storageUsedProfiles: number; storageQuotaBytes: number; - storageQuotaGB: number; archivedItemCount: number; crawlCount: number; uploadCount: number; @@ -26,6 +27,7 @@ type Metrics = { collectionsCount: number; publicCollectionsCount: number; }; +const BYTES_PER_GB = 1e9; @localized() export class Dashboard extends LiteElement { @@ -41,6 +43,13 @@ export class Dashboard extends LiteElement { @state() private metrics?: Metrics; + private readonly colors = { + default: "neutral", + crawls: "green", + uploads: "sky", + browserProfiles: "indigo", + }; + willUpdate(changedProperties: PropertyValues) { if (changedProperties.has("orgId")) { this.fetchMetrics(); @@ -69,36 +78,59 @@ export class Dashboard extends LiteElement { ${this.renderCard( msg("Storage"), (metrics) => html` - - - ${msg("Used")} - + ${when(metrics.storageQuotaBytes, () => + this.renderStorageMeter(metrics) + )} - ${this.renderStat({ - value: metrics.archivedItemCount, - singleLabel: msg("Archived Item"), - pluralLabel: msg("Archived Items"), - iconProps: { name: "file-zip-fill" }, - })} + ${when( + !metrics.storageQuotaBytes, + () => html` + ${this.renderStat({ + value: html``, + singleLabel: msg("of Data Stored"), + pluralLabel: msg("of Data Stored"), + iconProps: { name: "device-hdd-fill" }, + })} + + ` + )} ${this.renderStat({ value: metrics.crawlCount, singleLabel: msg("Crawl"), pluralLabel: msg("Crawls"), - iconProps: { name: "gear-wide-connected" }, + iconProps: { + name: "gear-wide-connected", + color: this.colors.crawls, + }, })} ${this.renderStat({ value: metrics.uploadCount, singleLabel: msg("Upload"), pluralLabel: msg("Uploads"), - iconProps: { name: "upload" }, + iconProps: { name: "upload", color: this.colors.uploads }, })} ${this.renderStat({ value: metrics.profileCount, singleLabel: msg("Browser Profile"), pluralLabel: msg("Browser Profiles"), - iconProps: { name: "window-fullscreen" }, + iconProps: { + name: "window-fullscreen", + color: this.colors.browserProfiles, + }, + })} + + ${this.renderStat({ + value: metrics.archivedItemCount, + singleLabel: msg("Archived Item"), + pluralLabel: msg("Archived Items"), + iconProps: { name: "file-zip-fill" }, })} `, @@ -141,13 +173,17 @@ export class Dashboard extends LiteElement { value: metrics.workflowsRunningCount, singleLabel: msg("Crawl Running"), pluralLabel: msg("Crawls Running"), - iconProps: { name: "dot", library: "app" }, + 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" }, + iconProps: { name: "hourglass-split", color: "purple" }, })} ${this.renderStat({ value: metrics.pageCount, @@ -185,7 +221,7 @@ export class Dashboard extends LiteElement { value: metrics.publicCollectionsCount, singleLabel: msg("Shareable Collection"), pluralLabel: msg("Shareable Collections"), - iconProps: { name: "people-fill" }, + iconProps: { name: "people-fill", color: "emerald" }, })} `, @@ -207,6 +243,104 @@ export class Dashboard extends LiteElement { `; } + private renderStorageMeter(metrics: Metrics) { + // Account for usage that exceeds max + const maxBytes = Math.max( + metrics.storageUsedBytes, + metrics.storageQuotaBytes + ); + const isStorageFull = 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")} + + `, + () => html` + + ${msg("Available")} + ` + )} + + + + ${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 renderCard( title: string, renderContent: (metric: Metrics) => TemplateResult, @@ -233,26 +367,37 @@ export class Dashboard extends LiteElement { } private renderStat(stat: { - value: number; + value: number | string | TemplateResult; singleLabel: string; pluralLabel: string; - iconProps: { name: string; library?: string }; + iconProps: { name: string; library?: string; color?: string }; }) { + const { value, iconProps } = stat; return html` - ${stat.value === 1 ? stat.singleLabel : stat.pluralLabel} + ${value === 1 ? stat.singleLabel : stat.pluralLabel} - ${stat.value.toLocaleString()} + + ${typeof value === "number" ? value.toLocaleString() : value} + `; } + 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(