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