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:
sua yoo 2023-09-27 10:38:59 -07:00 committed by GitHub
parent e6bccac953
commit e5cc70754e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 376 additions and 26 deletions

View File

@ -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);

View 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)";
}
});
}
}

View File

@ -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(