feat: Update billing tab with usage & portal URL check (#1995)
- Hides usage table from dashboard if billing is enabled - Shows usage table in billing settings - Updates usage table column headings - Fixes `portalUrl` task running unnecessarily
This commit is contained in:
parent
7fa2b61b29
commit
96e48b001b
@ -1 +1,2 @@
|
|||||||
import("./org-status-banner");
|
import("./org-status-banner");
|
||||||
|
import("./usage-history-table");
|
||||||
|
|||||||
195
frontend/src/features/org/usage-history-table.ts
Normal file
195
frontend/src/features/org/usage-history-table.ts
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
import { localized, msg } from "@lit/localize";
|
||||||
|
import { html } from "lit";
|
||||||
|
import { customElement, property } from "lit/decorators.js";
|
||||||
|
|
||||||
|
import { TailwindElement } from "@/classes/TailwindElement";
|
||||||
|
import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter";
|
||||||
|
import { getLocale } from "@/utils/localization";
|
||||||
|
import type { OrgData, YearMonth } from "@/utils/orgs";
|
||||||
|
|
||||||
|
@localized()
|
||||||
|
@customElement("btrix-usage-history-table")
|
||||||
|
export class UsageHistoryTable extends TailwindElement {
|
||||||
|
@property({ type: Object })
|
||||||
|
org: OrgData | null = null;
|
||||||
|
|
||||||
|
private readonly hasMonthlyTime = () =>
|
||||||
|
this.org?.monthlyExecSeconds &&
|
||||||
|
Object.keys(this.org.monthlyExecSeconds).length;
|
||||||
|
|
||||||
|
private readonly hasExtraTime = () =>
|
||||||
|
this.org?.extraExecSeconds && Object.keys(this.org.extraExecSeconds).length;
|
||||||
|
|
||||||
|
private readonly hasGiftedTime = () =>
|
||||||
|
this.org?.giftedExecSeconds &&
|
||||||
|
Object.keys(this.org.giftedExecSeconds).length;
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.org) return;
|
||||||
|
|
||||||
|
if (this.org.usage && !Object.keys(this.org.usage).length) {
|
||||||
|
return html`
|
||||||
|
<p class="text-center text-neutral-500">
|
||||||
|
${msg("No usage history to show.")}
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usageTableCols = [
|
||||||
|
msg("Month"),
|
||||||
|
html`
|
||||||
|
${msg("Elapsed Time")}
|
||||||
|
<sl-tooltip>
|
||||||
|
<div slot="content" style="text-transform: initial">
|
||||||
|
${msg(
|
||||||
|
"Total duration of crawls and QA analysis runs, from start to finish",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<sl-icon name="info-circle" style="vertical-align: -.175em"></sl-icon>
|
||||||
|
</sl-tooltip>
|
||||||
|
`,
|
||||||
|
html`
|
||||||
|
${msg("Execution Time")}
|
||||||
|
<sl-tooltip>
|
||||||
|
<div slot="content" style="text-transform: initial">
|
||||||
|
${msg(
|
||||||
|
"Aggregated time across all crawler instances that the crawler was actively executing a crawl or QA analysis run, i.e. not in a waiting state",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<sl-icon name="info-circle" style="vertical-align: -.175em"></sl-icon>
|
||||||
|
</sl-tooltip>
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (this.hasMonthlyTime()) {
|
||||||
|
usageTableCols.push(
|
||||||
|
html`${msg("Billable Execution Time")}
|
||||||
|
<sl-tooltip>
|
||||||
|
<div slot="content" style="text-transform: initial">
|
||||||
|
${msg(
|
||||||
|
"Execution time used that is billable to the current month of the plan",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<sl-icon
|
||||||
|
name="info-circle"
|
||||||
|
style="vertical-align: -.175em"
|
||||||
|
></sl-icon>
|
||||||
|
</sl-tooltip>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this.hasExtraTime()) {
|
||||||
|
usageTableCols.push(
|
||||||
|
html`${msg("Rollover Execution Time")}
|
||||||
|
<sl-tooltip>
|
||||||
|
<div slot="content" style="text-transform: initial">
|
||||||
|
${msg(
|
||||||
|
"Additional execution time used, of which any extra minutes will roll over to next month as billable time",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<sl-icon
|
||||||
|
name="info-circle"
|
||||||
|
style="vertical-align: -.175em"
|
||||||
|
></sl-icon>
|
||||||
|
</sl-tooltip>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this.hasGiftedTime()) {
|
||||||
|
usageTableCols.push(
|
||||||
|
html`${msg("Gifted Execution Time")}
|
||||||
|
<sl-tooltip>
|
||||||
|
<div slot="content" style="text-transform: initial">
|
||||||
|
${msg("Execution time used that is free of charge")}
|
||||||
|
</div>
|
||||||
|
<sl-icon
|
||||||
|
name="info-circle"
|
||||||
|
style="vertical-align: -.175em"
|
||||||
|
></sl-icon>
|
||||||
|
</sl-tooltip>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = (Object.entries(this.org.usage || {}) as [YearMonth, number][])
|
||||||
|
// Sort latest
|
||||||
|
.reverse()
|
||||||
|
.map(([mY, crawlTime]) => {
|
||||||
|
if (!this.org) return [];
|
||||||
|
|
||||||
|
let monthlySecondsUsed = this.org.monthlyExecSeconds?.[mY] || 0;
|
||||||
|
let maxMonthlySeconds = 0;
|
||||||
|
if (this.org.quotas.maxExecMinutesPerMonth) {
|
||||||
|
maxMonthlySeconds = this.org.quotas.maxExecMinutesPerMonth * 60;
|
||||||
|
}
|
||||||
|
if (monthlySecondsUsed > maxMonthlySeconds) {
|
||||||
|
monthlySecondsUsed = maxMonthlySeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
let extraSecondsUsed = this.org.extraExecSeconds?.[mY] || 0;
|
||||||
|
let maxExtraSeconds = 0;
|
||||||
|
if (this.org.quotas.extraExecMinutes) {
|
||||||
|
maxExtraSeconds = this.org.quotas.extraExecMinutes * 60;
|
||||||
|
}
|
||||||
|
if (extraSecondsUsed > maxExtraSeconds) {
|
||||||
|
extraSecondsUsed = maxExtraSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
let giftedSecondsUsed = this.org.giftedExecSeconds?.[mY] || 0;
|
||||||
|
let maxGiftedSeconds = 0;
|
||||||
|
if (this.org.quotas.giftedExecMinutes) {
|
||||||
|
maxGiftedSeconds = this.org.quotas.giftedExecMinutes * 60;
|
||||||
|
}
|
||||||
|
if (giftedSecondsUsed > maxGiftedSeconds) {
|
||||||
|
giftedSecondsUsed = maxGiftedSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalSecondsUsed = this.org.crawlExecSeconds?.[mY] || 0;
|
||||||
|
const totalMaxQuota =
|
||||||
|
maxMonthlySeconds + maxExtraSeconds + maxGiftedSeconds;
|
||||||
|
if (totalSecondsUsed > totalMaxQuota) {
|
||||||
|
totalSecondsUsed = totalMaxQuota;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableRows = [
|
||||||
|
html`
|
||||||
|
<sl-format-date
|
||||||
|
lang=${getLocale()}
|
||||||
|
date="${mY}-15T00:00:00.000Z"
|
||||||
|
time-zone="utc"
|
||||||
|
month="long"
|
||||||
|
year="numeric"
|
||||||
|
>
|
||||||
|
</sl-format-date>
|
||||||
|
`,
|
||||||
|
humanizeExecutionSeconds(crawlTime || 0),
|
||||||
|
totalSecondsUsed ? humanizeExecutionSeconds(totalSecondsUsed) : "--",
|
||||||
|
];
|
||||||
|
if (this.hasMonthlyTime()) {
|
||||||
|
tableRows.push(
|
||||||
|
monthlySecondsUsed
|
||||||
|
? humanizeExecutionSeconds(monthlySecondsUsed)
|
||||||
|
: "--",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this.hasExtraTime()) {
|
||||||
|
tableRows.push(
|
||||||
|
extraSecondsUsed
|
||||||
|
? humanizeExecutionSeconds(extraSecondsUsed)
|
||||||
|
: "--",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this.hasGiftedTime()) {
|
||||||
|
tableRows.push(
|
||||||
|
giftedSecondsUsed
|
||||||
|
? humanizeExecutionSeconds(giftedSecondsUsed)
|
||||||
|
: "--",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return tableRows;
|
||||||
|
});
|
||||||
|
return html`
|
||||||
|
<btrix-data-table
|
||||||
|
.columns=${usageTableCols}
|
||||||
|
.rows=${rows}
|
||||||
|
></btrix-data-table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,7 +10,6 @@ import type { SelectNewDialogEvent } from ".";
|
|||||||
import type { AuthState } from "@/utils/AuthService";
|
import type { AuthState } from "@/utils/AuthService";
|
||||||
import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter";
|
import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter";
|
||||||
import LiteElement, { html } from "@/utils/LiteElement";
|
import LiteElement, { html } from "@/utils/LiteElement";
|
||||||
import { getLocale } from "@/utils/localization";
|
|
||||||
import type { OrgData, YearMonth } from "@/utils/orgs";
|
import type { OrgData, YearMonth } from "@/utils/orgs";
|
||||||
|
|
||||||
type Metrics = {
|
type Metrics = {
|
||||||
@ -137,7 +136,7 @@ export class Dashboard extends LiteElement {
|
|||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<div class="flex flex-col gap-6 md:flex-row">
|
<div class="mb-10 flex flex-col gap-6 md:flex-row">
|
||||||
${this.renderCard(
|
${this.renderCard(
|
||||||
msg("Storage"),
|
msg("Storage"),
|
||||||
(metrics) => html`
|
(metrics) => html`
|
||||||
@ -252,7 +251,21 @@ export class Dashboard extends LiteElement {
|
|||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<section class="mt-10">${this.renderUsageHistory()}</section>
|
${when(
|
||||||
|
this.appState.settings &&
|
||||||
|
!this.appState.settings.billingEnabled &&
|
||||||
|
this.org,
|
||||||
|
(org) => html`
|
||||||
|
<section class="mb-10">
|
||||||
|
<btrix-details open>
|
||||||
|
<span slot="title">${msg("Usage History")}</span>
|
||||||
|
<btrix-usage-history-table
|
||||||
|
.org=${org}
|
||||||
|
></btrix-usage-history-table>
|
||||||
|
</btrix-details>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
</main> `;
|
</main> `;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -667,179 +680,6 @@ export class Dashboard extends LiteElement {
|
|||||||
<sl-skeleton class="mb-3" effect="sheen"></sl-skeleton>
|
<sl-skeleton class="mb-3" effect="sheen"></sl-skeleton>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
private readonly hasMonthlyTime = () =>
|
|
||||||
this.org?.monthlyExecSeconds &&
|
|
||||||
Object.keys(this.org.monthlyExecSeconds).length;
|
|
||||||
|
|
||||||
private readonly hasExtraTime = () =>
|
|
||||||
this.org?.extraExecSeconds && Object.keys(this.org.extraExecSeconds).length;
|
|
||||||
|
|
||||||
private readonly hasGiftedTime = () =>
|
|
||||||
this.org?.giftedExecSeconds &&
|
|
||||||
Object.keys(this.org.giftedExecSeconds).length;
|
|
||||||
|
|
||||||
private renderUsageHistory() {
|
|
||||||
if (!this.org) return;
|
|
||||||
|
|
||||||
const usageTableCols = [
|
|
||||||
msg("Month"),
|
|
||||||
html`
|
|
||||||
${msg("Elapsed Time")}
|
|
||||||
<sl-tooltip>
|
|
||||||
<div slot="content" style="text-transform: initial">
|
|
||||||
${msg("Total time elapsed between when crawls started and ended")}
|
|
||||||
</div>
|
|
||||||
<sl-icon name="info-circle" style="vertical-align: -.175em"></sl-icon>
|
|
||||||
</sl-tooltip>
|
|
||||||
`,
|
|
||||||
html`
|
|
||||||
${msg("Total Execution Time")}
|
|
||||||
<sl-tooltip>
|
|
||||||
<div slot="content" style="text-transform: initial">
|
|
||||||
${msg(
|
|
||||||
"Total billable time of all crawler instances this used month",
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<sl-icon name="info-circle" style="vertical-align: -.175em"></sl-icon>
|
|
||||||
</sl-tooltip>
|
|
||||||
`,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (this.hasMonthlyTime()) {
|
|
||||||
usageTableCols.push(
|
|
||||||
html`${msg("Execution: Monthly")}
|
|
||||||
<sl-tooltip>
|
|
||||||
<div slot="content" style="text-transform: initial">
|
|
||||||
${msg("Billable time used, included with monthly plan")}
|
|
||||||
</div>
|
|
||||||
<sl-icon
|
|
||||||
name="info-circle"
|
|
||||||
style="vertical-align: -.175em"
|
|
||||||
></sl-icon>
|
|
||||||
</sl-tooltip>`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (this.hasExtraTime()) {
|
|
||||||
usageTableCols.push(
|
|
||||||
html`${msg("Execution: Extra")}
|
|
||||||
<sl-tooltip>
|
|
||||||
<div slot="content" style="text-transform: initial">
|
|
||||||
${msg(
|
|
||||||
"Additional units of billable time used, any extra minutes will roll over to next month",
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<sl-icon
|
|
||||||
name="info-circle"
|
|
||||||
style="vertical-align: -.175em"
|
|
||||||
></sl-icon>
|
|
||||||
</sl-tooltip>`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (this.hasGiftedTime()) {
|
|
||||||
usageTableCols.push(
|
|
||||||
html`${msg("Execution: Gifted")}
|
|
||||||
<sl-tooltip>
|
|
||||||
<div slot="content" style="text-transform: initial">
|
|
||||||
${msg(
|
|
||||||
"Usage of execution time added to your account free of charge",
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<sl-icon
|
|
||||||
name="info-circle"
|
|
||||||
style="vertical-align: -.175em"
|
|
||||||
></sl-icon>
|
|
||||||
</sl-tooltip>`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = (Object.entries(this.org.usage || {}) as [YearMonth, number][])
|
|
||||||
// Sort latest
|
|
||||||
.reverse()
|
|
||||||
.map(([mY, crawlTime]) => {
|
|
||||||
if (!this.org) return [];
|
|
||||||
|
|
||||||
let monthlySecondsUsed = this.org.monthlyExecSeconds?.[mY] || 0;
|
|
||||||
let maxMonthlySeconds = 0;
|
|
||||||
if (this.org.quotas.maxExecMinutesPerMonth) {
|
|
||||||
maxMonthlySeconds = this.org.quotas.maxExecMinutesPerMonth * 60;
|
|
||||||
}
|
|
||||||
if (monthlySecondsUsed > maxMonthlySeconds) {
|
|
||||||
monthlySecondsUsed = maxMonthlySeconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
let extraSecondsUsed = this.org.extraExecSeconds?.[mY] || 0;
|
|
||||||
let maxExtraSeconds = 0;
|
|
||||||
if (this.org.quotas.extraExecMinutes) {
|
|
||||||
maxExtraSeconds = this.org.quotas.extraExecMinutes * 60;
|
|
||||||
}
|
|
||||||
if (extraSecondsUsed > maxExtraSeconds) {
|
|
||||||
extraSecondsUsed = maxExtraSeconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
let giftedSecondsUsed = this.org.giftedExecSeconds?.[mY] || 0;
|
|
||||||
let maxGiftedSeconds = 0;
|
|
||||||
if (this.org.quotas.giftedExecMinutes) {
|
|
||||||
maxGiftedSeconds = this.org.quotas.giftedExecMinutes * 60;
|
|
||||||
}
|
|
||||||
if (giftedSecondsUsed > maxGiftedSeconds) {
|
|
||||||
giftedSecondsUsed = maxGiftedSeconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalSecondsUsed = this.org.crawlExecSeconds?.[mY] || 0;
|
|
||||||
const totalMaxQuota =
|
|
||||||
maxMonthlySeconds + maxExtraSeconds + maxGiftedSeconds;
|
|
||||||
if (totalSecondsUsed > totalMaxQuota) {
|
|
||||||
totalSecondsUsed = totalMaxQuota;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableRows = [
|
|
||||||
html`
|
|
||||||
<sl-format-date
|
|
||||||
lang=${getLocale()}
|
|
||||||
date="${mY}-15T00:00:00.000Z"
|
|
||||||
time-zone="utc"
|
|
||||||
month="long"
|
|
||||||
year="numeric"
|
|
||||||
>
|
|
||||||
</sl-format-date>
|
|
||||||
`,
|
|
||||||
humanizeExecutionSeconds(crawlTime || 0),
|
|
||||||
totalSecondsUsed ? humanizeExecutionSeconds(totalSecondsUsed) : "--",
|
|
||||||
];
|
|
||||||
if (this.hasMonthlyTime()) {
|
|
||||||
tableRows.push(
|
|
||||||
monthlySecondsUsed
|
|
||||||
? humanizeExecutionSeconds(monthlySecondsUsed)
|
|
||||||
: "--",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (this.hasExtraTime()) {
|
|
||||||
tableRows.push(
|
|
||||||
extraSecondsUsed
|
|
||||||
? humanizeExecutionSeconds(extraSecondsUsed)
|
|
||||||
: "--",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (this.hasGiftedTime()) {
|
|
||||||
tableRows.push(
|
|
||||||
giftedSecondsUsed
|
|
||||||
? humanizeExecutionSeconds(giftedSecondsUsed)
|
|
||||||
: "--",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return tableRows;
|
|
||||||
});
|
|
||||||
return html`
|
|
||||||
<btrix-details>
|
|
||||||
<span slot="title">${msg("Usage History")}</span>
|
|
||||||
<btrix-data-table
|
|
||||||
.columns=${usageTableCols}
|
|
||||||
.rows=${rows}
|
|
||||||
></btrix-data-table>
|
|
||||||
</btrix-details>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderPercentage(ratio: number) {
|
private renderPercentage(ratio: number) {
|
||||||
const percent = ratio * 100;
|
const percent = ratio * 100;
|
||||||
if (percent < 1) return `<1%`;
|
if (percent < 1) return `<1%`;
|
||||||
|
|||||||
@ -74,10 +74,15 @@ export class OrgSettingsBilling extends TailwindElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private readonly portalUrl = new Task(this, {
|
private readonly portalUrl = new Task(this, {
|
||||||
task: async ([org, authState]) => {
|
task: async ([appState]) => {
|
||||||
if (!org || !authState) throw new Error("Missing args");
|
if (!appState.settings?.billingEnabled || !appState.org?.subscription)
|
||||||
|
return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { portalUrl } = await this.getPortalUrl(org.id, authState);
|
const { portalUrl } = await this.getPortalUrl(
|
||||||
|
appState.org.id,
|
||||||
|
this.authState!,
|
||||||
|
);
|
||||||
|
|
||||||
if (portalUrl) {
|
if (portalUrl) {
|
||||||
return portalUrl;
|
return portalUrl;
|
||||||
@ -92,7 +97,7 @@ export class OrgSettingsBilling extends TailwindElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
args: () => [this.org, this.authState] as const,
|
args: () => [this.appState] as const,
|
||||||
});
|
});
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@ -148,6 +153,9 @@ export class OrgSettingsBilling extends TailwindElement {
|
|||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
<h5 class="mb-2 mt-4 text-xs leading-none text-neutral-500">
|
||||||
|
${msg("Monthly quota")}
|
||||||
|
</h5>
|
||||||
${this.renderQuotas(org.quotas)}
|
${this.renderQuotas(org.quotas)}
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
@ -192,6 +200,15 @@ export class OrgSettingsBilling extends TailwindElement {
|
|||||||
`,
|
`,
|
||||||
],
|
],
|
||||||
])}
|
])}
|
||||||
|
|
||||||
|
<div class="p-4">
|
||||||
|
<btrix-section-heading style="--margin: var(--sl-spacing-medium)">
|
||||||
|
<h4>${msg("Usage History")}</h4>
|
||||||
|
</btrix-section-heading>
|
||||||
|
<btrix-usage-history-table
|
||||||
|
.org=${ifDefined(this.org)}
|
||||||
|
></btrix-usage-history-table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -263,7 +280,7 @@ export class OrgSettingsBilling extends TailwindElement {
|
|||||||
<ul class="leading-relaxed text-neutral-700">
|
<ul class="leading-relaxed text-neutral-700">
|
||||||
<li>
|
<li>
|
||||||
${msg(
|
${msg(
|
||||||
str`${quotas.maxExecMinutesPerMonth ? humanizeSeconds(quotas.maxExecMinutesPerMonth * 60, undefined, undefined, "long") : msg("Unlimited minutes")} of crawling and QA analysis time per month`,
|
str`${quotas.maxExecMinutesPerMonth ? humanizeSeconds(quotas.maxExecMinutesPerMonth * 60, undefined, undefined, "long") : msg("Unlimited minutes")} of crawl and QA analysis execution time`,
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user