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 }) => { | import("./search-combobox").then(({ SearchCombobox }) => { | ||||||
|   customElements.define("btrix-search-combobox", 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-alert", Alert); | ||||||
| customElements.define("btrix-input", Input); | customElements.define("btrix-input", Input); | ||||||
| customElements.define("btrix-time-input", TimeInput); | 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 = { | type Metrics = { | ||||||
|   storageUsedBytes: number; |   storageUsedBytes: number; | ||||||
|   storageUsedGB: number; |   storageUsedCrawls: number; | ||||||
|  |   storageUsedUploads: number; | ||||||
|  |   storageUsedProfiles: number; | ||||||
|   storageQuotaBytes: number; |   storageQuotaBytes: number; | ||||||
|   storageQuotaGB: number; |  | ||||||
|   archivedItemCount: number; |   archivedItemCount: number; | ||||||
|   crawlCount: number; |   crawlCount: number; | ||||||
|   uploadCount: number; |   uploadCount: number; | ||||||
| @ -26,6 +27,7 @@ type Metrics = { | |||||||
|   collectionsCount: number; |   collectionsCount: number; | ||||||
|   publicCollectionsCount: number; |   publicCollectionsCount: number; | ||||||
| }; | }; | ||||||
|  | const BYTES_PER_GB = 1e9; | ||||||
| 
 | 
 | ||||||
| @localized() | @localized() | ||||||
| export class Dashboard extends LiteElement { | export class Dashboard extends LiteElement { | ||||||
| @ -41,6 +43,13 @@ export class Dashboard extends LiteElement { | |||||||
|   @state() |   @state() | ||||||
|   private metrics?: Metrics; |   private metrics?: Metrics; | ||||||
| 
 | 
 | ||||||
|  |   private readonly colors = { | ||||||
|  |     default: "neutral", | ||||||
|  |     crawls: "green", | ||||||
|  |     uploads: "sky", | ||||||
|  |     browserProfiles: "indigo", | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|   willUpdate(changedProperties: PropertyValues<this>) { |   willUpdate(changedProperties: PropertyValues<this>) { | ||||||
|     if (changedProperties.has("orgId")) { |     if (changedProperties.has("orgId")) { | ||||||
|       this.fetchMetrics(); |       this.fetchMetrics(); | ||||||
| @ -69,36 +78,59 @@ export class Dashboard extends LiteElement { | |||||||
|           ${this.renderCard( |           ${this.renderCard( | ||||||
|             msg("Storage"), |             msg("Storage"), | ||||||
|             (metrics) => html` |             (metrics) => html` | ||||||
|               <div class="font-semibold mb-3"> |               ${when(metrics.storageQuotaBytes, () => | ||||||
|                 <sl-format-bytes |                 this.renderStorageMeter(metrics) | ||||||
|                   value=${metrics.storageUsedBytes ?? 0} |               )} | ||||||
|                 ></sl-format-bytes> |  | ||||||
|                 ${msg("Used")} |  | ||||||
|               </div> |  | ||||||
|               <dl> |               <dl> | ||||||
|                 ${this.renderStat({ |                 ${when( | ||||||
|                   value: metrics.archivedItemCount, |                   !metrics.storageQuotaBytes, | ||||||
|                   singleLabel: msg("Archived Item"), |                   () => html` | ||||||
|                   pluralLabel: msg("Archived Items"), |                     ${this.renderStat({ | ||||||
|                   iconProps: { name: "file-zip-fill" }, |                       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({ |                 ${this.renderStat({ | ||||||
|                   value: metrics.crawlCount, |                   value: metrics.crawlCount, | ||||||
|                   singleLabel: msg("Crawl"), |                   singleLabel: msg("Crawl"), | ||||||
|                   pluralLabel: msg("Crawls"), |                   pluralLabel: msg("Crawls"), | ||||||
|                   iconProps: { name: "gear-wide-connected" }, |                   iconProps: { | ||||||
|  |                     name: "gear-wide-connected", | ||||||
|  |                     color: this.colors.crawls, | ||||||
|  |                   }, | ||||||
|                 })} |                 })} | ||||||
|                 ${this.renderStat({ |                 ${this.renderStat({ | ||||||
|                   value: metrics.uploadCount, |                   value: metrics.uploadCount, | ||||||
|                   singleLabel: msg("Upload"), |                   singleLabel: msg("Upload"), | ||||||
|                   pluralLabel: msg("Uploads"), |                   pluralLabel: msg("Uploads"), | ||||||
|                   iconProps: { name: "upload" }, |                   iconProps: { name: "upload", color: this.colors.uploads }, | ||||||
|                 })} |                 })} | ||||||
|                 ${this.renderStat({ |                 ${this.renderStat({ | ||||||
|                   value: metrics.profileCount, |                   value: metrics.profileCount, | ||||||
|                   singleLabel: msg("Browser Profile"), |                   singleLabel: msg("Browser Profile"), | ||||||
|                   pluralLabel: msg("Browser Profiles"), |                   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> |               </dl> | ||||||
|             `,
 |             `,
 | ||||||
| @ -141,13 +173,17 @@ export class Dashboard extends LiteElement { | |||||||
|                   value: metrics.workflowsRunningCount, |                   value: metrics.workflowsRunningCount, | ||||||
|                   singleLabel: msg("Crawl Running"), |                   singleLabel: msg("Crawl Running"), | ||||||
|                   pluralLabel: msg("Crawls Running"), |                   pluralLabel: msg("Crawls Running"), | ||||||
|                   iconProps: { name: "dot", library: "app" }, |                   iconProps: { | ||||||
|  |                     name: "dot", | ||||||
|  |                     library: "app", | ||||||
|  |                     color: metrics.workflowsRunningCount ? "green" : "neutral", | ||||||
|  |                   }, | ||||||
|                 })} |                 })} | ||||||
|                 ${this.renderStat({ |                 ${this.renderStat({ | ||||||
|                   value: metrics.workflowsQueuedCount, |                   value: metrics.workflowsQueuedCount, | ||||||
|                   singleLabel: msg("Crawl Workflow Waiting"), |                   singleLabel: msg("Crawl Workflow Waiting"), | ||||||
|                   pluralLabel: msg("Crawl Workflows Waiting"), |                   pluralLabel: msg("Crawl Workflows Waiting"), | ||||||
|                   iconProps: { name: "hourglass-split" }, |                   iconProps: { name: "hourglass-split", color: "purple" }, | ||||||
|                 })} |                 })} | ||||||
|                 ${this.renderStat({ |                 ${this.renderStat({ | ||||||
|                   value: metrics.pageCount, |                   value: metrics.pageCount, | ||||||
| @ -185,7 +221,7 @@ export class Dashboard extends LiteElement { | |||||||
|                   value: metrics.publicCollectionsCount, |                   value: metrics.publicCollectionsCount, | ||||||
|                   singleLabel: msg("Shareable Collection"), |                   singleLabel: msg("Shareable Collection"), | ||||||
|                   pluralLabel: msg("Shareable Collections"), |                   pluralLabel: msg("Shareable Collections"), | ||||||
|                   iconProps: { name: "people-fill" }, |                   iconProps: { name: "people-fill", color: "emerald" }, | ||||||
|                 })} |                 })} | ||||||
|               </dl> |               </dl> | ||||||
|             `,
 |             `,
 | ||||||
| @ -207,6 +243,104 @@ export class Dashboard extends LiteElement { | |||||||
|       </main> `;
 |       </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( |   private renderCard( | ||||||
|     title: string, |     title: string, | ||||||
|     renderContent: (metric: Metrics) => TemplateResult, |     renderContent: (metric: Metrics) => TemplateResult, | ||||||
| @ -233,26 +367,37 @@ export class Dashboard extends LiteElement { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   private renderStat(stat: { |   private renderStat(stat: { | ||||||
|     value: number; |     value: number | string | TemplateResult; | ||||||
|     singleLabel: string; |     singleLabel: string; | ||||||
|     pluralLabel: string; |     pluralLabel: string; | ||||||
|     iconProps: { name: string; library?: string }; |     iconProps: { name: string; library?: string; color?: string }; | ||||||
|   }) { |   }) { | ||||||
|  |     const { value, iconProps } = stat; | ||||||
|     return html` |     return html` | ||||||
|       <div class="flex items-center mb-2 last:mb-0"> |       <div class="flex items-center mb-2 last:mb-0"> | ||||||
|         <sl-icon |         <sl-icon | ||||||
|           class="text-base text-neutral-500 mr-2" |           class="text-base text-neutral-500 mr-2" | ||||||
|           name=${stat.iconProps.name} |           name=${iconProps.name} | ||||||
|           library=${ifDefined(stat.iconProps.library)} |           library=${ifDefined(iconProps.library)} | ||||||
|  |           style="color:var(--sl-color-${iconProps.color || | ||||||
|  |           this.colors.default}-500)" | ||||||
|         ></sl-icon> |         ></sl-icon> | ||||||
|         <dt class="order-last"> |         <dt class="order-last"> | ||||||
|           ${stat.value === 1 ? stat.singleLabel : stat.pluralLabel} |           ${value === 1 ? stat.singleLabel : stat.pluralLabel} | ||||||
|         </dt> |         </dt> | ||||||
|         <dd class="mr-1">${stat.value.toLocaleString()}</dd> |         <dd class="mr-1"> | ||||||
|  |           ${typeof value === "number" ? value.toLocaleString() : value} | ||||||
|  |         </dd> | ||||||
|       </div> |       </div> | ||||||
|     `;
 |     `;
 | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   private renderPercentage(ratio: number) { | ||||||
|  |     const percent = ratio * 100; | ||||||
|  |     if (percent < 1) return `<1%`; | ||||||
|  |     return `${percent.toFixed(2)}%`; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   private async fetchMetrics() { |   private async fetchMetrics() { | ||||||
|     try { |     try { | ||||||
|       const data = await this.apiFetch( |       const data = await this.apiFetch( | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user