Show org storage quotas in dashboard (#1210)
- Displays storage quota in subdivided meter - Updates icon colors - Adds new <btrix-meter> component --------- Co-authored-by: Henry Wilkinson <henry@wilkinson.graphics>
This commit is contained in:
		
							parent
							
								
									e6bccac953
								
							
						
					
					
						commit
						e5cc70754e
					
				| @ -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); | ||||
|  | ||||
							
								
								
									
										201
									
								
								frontend/src/components/meter.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								frontend/src/components/meter.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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`<sl-tooltip>
 | ||||
|       <div slot="content"><slot></slot></div> | ||||
|       <div class="bar" style="width:${this.value}%"></div> | ||||
|     </sl-tooltip>`; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Show scalar value within a range | ||||
|  * | ||||
|  * Usage example: | ||||
|  * ```ts
 | ||||
|  * <btrix-meter max="50" value="40" low="10"></btrix-meter> | ||||
|  * ``` | ||||
|  */ | ||||
| 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<HTMLElement>; | ||||
| 
 | ||||
|   updated(changedProperties: PropertyValues<this>) { | ||||
|     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` | ||||
|       <div | ||||
|         class="meter" | ||||
|         role="meter" | ||||
|         aria-valuenow=${boundedValue} | ||||
|         aria-valuetext=${ifDefined(this.valueText)} | ||||
|         aria-valuemin=${this.min} | ||||
|         aria-valuemax=${this.max} | ||||
|       > | ||||
|         <sl-resize-observer @sl-resize=${this.onTrackResize}> | ||||
|           <div class="track"> | ||||
|             <div class="valueBar" style="width:${barWidth}"> | ||||
|               <slot @slotchange=${this.handleSlotchange}></slot> | ||||
|             </div> | ||||
|             ${this.value < this.max ? html`<slot name="available"></slot>` : ""} | ||||
|           </div> | ||||
|         </sl-resize-observer> | ||||
|         <div class="labels"> | ||||
|           <div class="label value" style="width:${barWidth}"> | ||||
|             <span class="valueText withSeparator"> | ||||
|               <slot name="valueLabel">${this.value}</slot> | ||||
|             </span> | ||||
|           </div> | ||||
|           <div class="label max"> | ||||
|             <span class="maxText"> | ||||
|               <slot name="maxLabel">${this.max}</slot> | ||||
|             </span> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     `;
 | ||||
|   } | ||||
| 
 | ||||
|   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)"; | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| @ -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<this>) { | ||||
|     if (changedProperties.has("orgId")) { | ||||
|       this.fetchMetrics(); | ||||
| @ -69,36 +78,59 @@ export class Dashboard extends LiteElement { | ||||
|           ${this.renderCard( | ||||
|             msg("Storage"), | ||||
|             (metrics) => html` | ||||
|               <div class="font-semibold mb-3"> | ||||
|                 <sl-format-bytes | ||||
|                   value=${metrics.storageUsedBytes ?? 0} | ||||
|                 ></sl-format-bytes> | ||||
|                 ${msg("Used")} | ||||
|               </div> | ||||
|               ${when(metrics.storageQuotaBytes, () => | ||||
|                 this.renderStorageMeter(metrics) | ||||
|               )} | ||||
|               <dl> | ||||
|                 ${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`<sl-format-bytes
 | ||||
|                         value=${metrics.storageUsedBytes ?? 0} | ||||
|                         display="narrow" | ||||
|                       ></sl-format-bytes>`, | ||||
|                       singleLabel: msg("of Data Stored"), | ||||
|                       pluralLabel: msg("of Data Stored"), | ||||
|                       iconProps: { name: "device-hdd-fill" }, | ||||
|                     })} | ||||
|                     <sl-divider | ||||
|                       style="--spacing:var(--sl-spacing-small)" | ||||
|                     ></sl-divider> | ||||
|                   ` | ||||
|                 )} | ||||
|                 ${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, | ||||
|                   }, | ||||
|                 })} | ||||
|                 <sl-divider | ||||
|                   style="--spacing:var(--sl-spacing-small)" | ||||
|                 ></sl-divider> | ||||
|                 ${this.renderStat({ | ||||
|                   value: metrics.archivedItemCount, | ||||
|                   singleLabel: msg("Archived Item"), | ||||
|                   pluralLabel: msg("Archived Items"), | ||||
|                   iconProps: { name: "file-zip-fill" }, | ||||
|                 })} | ||||
|               </dl> | ||||
|             `,
 | ||||
| @ -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" }, | ||||
|                 })} | ||||
|               </dl> | ||||
|             `,
 | ||||
| @ -207,6 +243,104 @@ export class Dashboard extends LiteElement { | ||||
|       </main> `;
 | ||||
|   } | ||||
| 
 | ||||
|   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` | ||||
|       <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> | ||||
|           `,
 | ||||
|           () => html` | ||||
|             <sl-format-bytes | ||||
|               value=${maxBytes - metrics.storageUsedBytes} | ||||
|             ></sl-format-bytes> | ||||
|             ${msg("Available")} | ||||
|           ` | ||||
|         )} | ||||
|       </div> | ||||
|       <div class="mb-2"> | ||||
|         <btrix-meter | ||||
|           value=${metrics.storageUsedBytes} | ||||
|           max=${maxBytes} | ||||
|           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 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` | ||||
|       <div class="flex items-center mb-2 last:mb-0"> | ||||
|         <sl-icon | ||||
|           class="text-base text-neutral-500 mr-2" | ||||
|           name=${stat.iconProps.name} | ||||
|           library=${ifDefined(stat.iconProps.library)} | ||||
|           name=${iconProps.name} | ||||
|           library=${ifDefined(iconProps.library)} | ||||
|           style="color:var(--sl-color-${iconProps.color || | ||||
|           this.colors.default}-500)" | ||||
|         ></sl-icon> | ||||
|         <dt class="order-last"> | ||||
|           ${stat.value === 1 ? stat.singleLabel : stat.pluralLabel} | ||||
|           ${value === 1 ? stat.singleLabel : stat.pluralLabel} | ||||
|         </dt> | ||||
|         <dd class="mr-1">${stat.value.toLocaleString()}</dd> | ||||
|         <dd class="mr-1"> | ||||
|           ${typeof value === "number" ? value.toLocaleString() : value} | ||||
|         </dd> | ||||
|       </div> | ||||
|     `;
 | ||||
|   } | ||||
| 
 | ||||
|   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( | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user