From dde1175bd0eeedd0607288888bf3e06e4d3a1081 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Tue, 23 Apr 2024 07:37:22 -0700 Subject: [PATCH] Add QA page analysis chart (#1725) --- frontend/package.json | 1 + frontend/src/components/ui/card.ts | 9 +- frontend/src/components/ui/meter.ts | 34 +- .../features/archived-items/crawl-status.ts | 4 + frontend/src/features/qa/qa-run-dropdown.ts | 16 +- .../archived-item-detail.ts | 18 +- .../pages/org/archived-item-detail/ui/qa.ts | 402 +++++++++++++----- frontend/src/utils/pluralize.ts | 63 +++ frontend/yarn.lock | 19 + 9 files changed, 423 insertions(+), 143 deletions(-) create mode 100644 frontend/src/utils/pluralize.ts diff --git a/frontend/package.json b/frontend/package.json index 59fa6020..ecc91233 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,7 @@ "@cheap-glitch/mi-cron": "^1.0.1", "@ianvs/prettier-plugin-sort-imports": "^4.2.1", "@lit/localize": "^0.12.1", + "@lit/task": "^1.0.0", "@novnc/novnc": "^1.4.0-beta", "@rollup/plugin-commonjs": "^18.0.0", "@shoelace-style/shoelace": "~2.13.0", diff --git a/frontend/src/components/ui/card.ts b/frontend/src/components/ui/card.ts index 61882f9d..d94e6ee7 100644 --- a/frontend/src/components/ui/card.ts +++ b/frontend/src/components/ui/card.ts @@ -8,10 +8,13 @@ export class Card extends TailwindElement { render() { return html`
-

+
-

-
+
+
diff --git a/frontend/src/components/ui/meter.ts b/frontend/src/components/ui/meter.ts index 4e978164..da1cdf31 100644 --- a/frontend/src/components/ui/meter.ts +++ b/frontend/src/components/ui/meter.ts @@ -1,4 +1,4 @@ -import { css, html, LitElement, type PropertyValues } from "lit"; +import { css, html, type PropertyValues } from "lit"; import { customElement, property, @@ -10,10 +10,12 @@ import { ifDefined } from "lit/directives/if-defined.js"; import { when } from "lit/directives/when.js"; import debounce from "lodash/fp/debounce"; +import { TailwindElement } from "@/classes/TailwindElement"; import type { UnderlyingFunction } from "@/types/utils"; +import { tw } from "@/utils/tailwind"; @customElement("btrix-meter-bar") -export class MeterBar extends LitElement { +export class MeterBar extends TailwindElement { /* Percentage of value / max */ @property({ type: Number }) value = 0; @@ -43,7 +45,7 @@ export class MeterBar extends LitElement { } @customElement("btrix-divided-meter-bar") -export class DividedMeterBar extends LitElement { +export class DividedMeterBar extends TailwindElement { /* Percentage of value / max */ @property({ type: Number }) value = 0; @@ -101,7 +103,7 @@ export class DividedMeterBar extends LitElement { * ``` */ @customElement("btrix-meter") -export class Meter extends LitElement { +export class Meter extends TailwindElement { @property({ type: Number }) min = 0; @@ -111,25 +113,30 @@ export class Meter extends LitElement { @property({ type: Number }) value = 0; - @property({ type: Array }) - subValues?: number[]; - @property({ type: String }) valueText?: string; - @query(".valueBar") - private readonly valueBar?: HTMLElement; - @query(".labels") private readonly labels?: HTMLElement; + @query(".valueBar") + private readonly valueBar?: HTMLElement; + @query(".maxText") private readonly maxText?: HTMLElement; + @queryAssignedElements({ slot: "valueLabel" }) + private readonly valueLabel!: HTMLElement[]; + // postcss-lit-disable-next-line static styles = css` + :host { + display: block; + } + .meter { position: relative; + width: 100%; } .track { @@ -185,6 +192,13 @@ export class Meter extends LitElement { } } + firstUpdated() { + // TODO refactor to check slot + if (!this.valueLabel.length) { + this.labels?.classList.add(tw`hidden`); + } + } + render() { // meter spec disallow values that exceed max const max = this.max ? Math.max(this.value, this.max) : this.value; diff --git a/frontend/src/features/archived-items/crawl-status.ts b/frontend/src/features/archived-items/crawl-status.ts index b5e2bd78..55960a94 100644 --- a/frontend/src/features/archived-items/crawl-status.ts +++ b/frontend/src/features/archived-items/crawl-status.ts @@ -25,6 +25,9 @@ export class CrawlStatus extends TailwindElement { @property({ type: Boolean }) stopping = false; + @property({ type: Boolean }) + hoist = false; + static styles = [ animatePulse, css` @@ -235,6 +238,7 @@ export class CrawlStatus extends TailwindElement { content=${label} @sl-hide=${(e: SlHideEvent) => e.stopPropagation()} @sl-after-hide=${(e: SlHideEvent) => e.stopPropagation()} + .hoist=${this.hoist} >
${icon}
diff --git a/frontend/src/features/qa/qa-run-dropdown.ts b/frontend/src/features/qa/qa-run-dropdown.ts index 61dabb08..e33e6f10 100644 --- a/frontend/src/features/qa/qa-run-dropdown.ts +++ b/frontend/src/features/qa/qa-run-dropdown.ts @@ -32,7 +32,14 @@ export class QaRunDropdown extends TailwindElement { ${selectedRun - ? formatDate(selectedRun.finished) + ? html` + ${formatDate(selectedRun.finished)} ` : msg("Select a QA run")} @@ -46,6 +53,13 @@ export class QaRunDropdown extends TailwindElement { ?checked=${isSelected} > ${formatDate(run.finished)} + `; })} diff --git a/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts b/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts index 420ed3c2..c5a93f77 100644 --- a/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts +++ b/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts @@ -27,7 +27,11 @@ import type { import type { QARun } from "@/types/qa"; import { isApiError } from "@/utils/api"; import type { AuthState } from "@/utils/AuthService"; -import { finishedCrawlStates, isActive } from "@/utils/crawler"; +import { + activeCrawlStates, + finishedCrawlStates, + isActive, +} from "@/utils/crawler"; import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter"; import { getLocale } from "@/utils/localization"; import { tw } from "@/utils/tailwind"; @@ -47,17 +51,9 @@ const SECTIONS = [ type SectionName = (typeof SECTIONS)[number]; const POLL_INTERVAL_SECONDS = 5; -const RUNNING_STATES = [ - "running", - "starting", - "waiting_capacity", - "waiting_org_limit", - "stopping", -] as CrawlState[]; - export const QA_RUNNING_STATES = [ "starting", - ...RUNNING_STATES, + ...activeCrawlStates, ] as CrawlState[]; /** @@ -149,7 +145,7 @@ export class ArchivedItemDetail extends TailwindElement { private get isActive(): boolean | null { if (!this.crawl) return null; - return RUNNING_STATES.includes(this.crawl.state); + return activeCrawlStates.includes(this.crawl.state); } private get isQAActive(): boolean | null { diff --git a/frontend/src/pages/org/archived-item-detail/ui/qa.ts b/frontend/src/pages/org/archived-item-detail/ui/qa.ts index 1aa337db..39722ee8 100644 --- a/frontend/src/pages/org/archived-item-detail/ui/qa.ts +++ b/frontend/src/pages/org/archived-item-detail/ui/qa.ts @@ -1,4 +1,5 @@ import { localized, msg, str } from "@lit/localize"; +import { Task } from "@lit/task"; import type { SlChangeEvent, SlSelect, @@ -27,7 +28,6 @@ import { NavigateController } from "@/controllers/navigate"; import { NotifyController } from "@/controllers/notify"; import { iconFor as iconForPageReview } from "@/features/qa/page-list/helpers"; import * as pageApproval from "@/features/qa/page-list/helpers/approval"; -// eslint-disable-next-line @typescript-eslint/no-unused-vars import type { SelectDetail } from "@/features/qa/qa-run-dropdown"; import type { APIPaginatedList, @@ -36,11 +36,35 @@ import type { } from "@/types/api"; import { type ArchivedItem, type ArchivedItemPage } from "@/types/crawler"; import type { QARun } from "@/types/qa"; -import { type AuthState } from "@/utils/AuthService"; -// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { type Auth, type AuthState } from "@/utils/AuthService"; import { finishedCrawlStates } from "@/utils/crawler"; import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter"; -import { getLocale, pluralize } from "@/utils/localization"; +import { formatNumber, getLocale } from "@/utils/localization"; +import { pluralOf } from "@/utils/pluralize"; + +type QAStatsThreshold = { + lowerBoundary: `${number}` | "No data"; + count: number; +}; +type QAStats = Record<"screenshotMatch" | "textMatch", QAStatsThreshold[]>; + +const qaStatsThresholds = [ + { + lowerBoundary: "0.0", + cssColor: "var(--sl-color-danger-500)", + label: msg("Severe Inconsistencies"), + }, + { + lowerBoundary: "0.5", + cssColor: "var(--sl-color-warning-500)", + label: msg("Moderate Inconsistencies"), + }, + { + lowerBoundary: "0.9", + cssColor: "var(--sl-color-success-500)", + label: msg("Good Match"), + }, +]; const notApplicable = () => html`${msg("n/a")}`; @@ -98,6 +122,16 @@ export class ArchivedItemDetailQA extends TailwindElement { @state() private pages?: APIPaginatedList; + private readonly qaStats = new Task(this, { + task: async ([orgId, crawlId, qaRunId, authState]) => { + if (!qaRunId || !authState) throw new Error("Missing args"); + const stats = await this.getQAStats(orgId, crawlId, qaRunId, authState); + return stats; + }, + args: () => + [this.orgId!, this.crawlId!, this.qaRunId, this.authState] as const, + }); + @state() private deleting: string | null = null; @@ -197,102 +231,24 @@ export class ArchivedItemDetailQA extends TailwindElement { - ${msg("Review Pages")} + ${msg("Pages")} + + + + ${msg("Analysis Runs")} - ${when( - this.qaRuns, - (qaRuns) => html` - - - ${msg("Analysis Runs")} - - `, - )} - ${ - // TODO un-hide this once we've got data in here - nothing - //
- //
- //

- // ${msg("QA Analysis")} - //

- // ${when(this.qaRuns, (qaRuns) => { - // const finishedQARuns = qaRuns.filter(({ state }) => - // finishedCrawlStates.includes(state), - // ); + ${when(this.mostRecentNonFailedQARun && this.qaRuns, (qaRuns) => + this.renderAnalysis(qaRuns), + )} - // if (!finishedQARuns.length) { - // return nothing; - // } - - // const mostRecentSelected = - // this.mostRecentNonFailedQARun && - // this.mostRecentNonFailedQARun.id === this.qaRunId; - // const latestFinishedSelected = - // this.qaRunId === finishedQARuns[0].id; - - // return html` - // - // - // ${mostRecentSelected - // ? msg("Current") - // : latestFinishedSelected - // ? msg("Last Finished") - // : msg("Outdated")} - // - // - // ) => - // (this.qaRunId = e.detail.item.id)} - // > - // `; - // })} - //
- // ${when( - // this.qaRuns, - // () => - // this.mostRecentNonFailedQARun - // ? this.renderAnalysis() - // : html` - //
- // ${msg( - // "This crawl hasn’t been analyzed yet. Run an analysis to access crawl quality metrics.", - // )} - //
- // `, - - // () => - // html`
- // - //
`, - // )} - //
- }

${msg("Pages")} (${( @@ -339,7 +295,7 @@ export class ArchivedItemDetailQA extends TailwindElement { class="col-span-4 flex h-full flex-col items-center justify-center gap-2 p-3 text-xs text-neutral-500" > - ${msg("No analysis runs found")} + ${msg("No analysis runs, yet")}

`; } @@ -452,7 +408,7 @@ export class ArchivedItemDetailQA extends TailwindElement { ${runToBeDeleted && html`
${msg( - str`This analysis run includes data for ${runToBeDeleted.stats.done} ${pluralize(runToBeDeleted.stats.done, { zero: msg("pages", { desc: 'plural form of "page" for zero pages', id: "pages.plural.zero" }), one: msg("page"), two: msg("pages", { desc: 'plural form of "page" for two pages', id: "pages.plural.two" }), few: msg("pages", { desc: 'plural form of "page" for few pages', id: "pages.plural.few" }), many: msg("pages", { desc: 'plural form of "page" for many pages', id: "pages.plural.many" }), other: msg("pages", { desc: 'plural form of "page" for multiple/other pages', id: "pages.plural.other" }) })} and was started on `, + str`This analysis run includes data for ${runToBeDeleted.stats.done} ${pluralOf("pages", runToBeDeleted.stats.done)} and was started on `, )} html`
`; - private renderAnalysis() { + private renderAnalysis(qaRuns: QARun[]) { const isRunning = this.mostRecentNonFailedQARun && QA_RUNNING_STATES.includes(this.mostRecentNonFailedQARun.state); + const qaRun = qaRuns.find(({ id }) => id === this.qaRunId); + + if (!qaRun && isRunning) { + return html` + ${msg("Running QA analysis on pages...")} + `; + } + + if (!qaRun) { + return html` + ${msg("This analysis run doesn't exist.")} + `; + } return html` ${isRunning @@ -505,20 +474,199 @@ export class ArchivedItemDetailQA extends TailwindElement { )} ` : nothing} -
- - ${msg("Screenshots")} - TODO - - - ${msg("Extracted Text")} - TODO - - - ${msg("Page Resources")} - TODO - -
+ +
+
+ ${msg("Page Match Analysis")} + ${when(this.qaRuns, (qaRuns) => { + const finishedQARuns = qaRuns.filter(({ state }) => + finishedCrawlStates.includes(state), + ); + + if (!finishedQARuns.length) { + return nothing; + } + + const mostRecentSelected = + this.mostRecentNonFailedQARun && + this.mostRecentNonFailedQARun.id === this.qaRunId; + const latestFinishedSelected = + this.qaRunId === finishedQARuns[0].id; + + return html` +
+ + + ${mostRecentSelected + ? msg("Current") + : latestFinishedSelected + ? msg("Last Finished") + : msg("Outdated")} + + + ) => + (this.qaRunId = e.detail.item.id)} + > +
+ `; + })} +
+
+ ${when( + qaRun.state.startsWith("stop") || + (qaRun.state === "complete" && + qaRun.stats.done < qaRun.stats.found), + () => + html` + + `, + )} + ${when( + qaRun.stats, + (stats) => html` +
+ ${formatNumber(stats.done)} / ${formatNumber(stats.found)} + ${pluralOf("pages", stats.found)} ${msg("analyzed")} +
+ `, + )} + + + +
+
+
+ + + + ${msg("Statistic")} + + + ${msg("Chart")} + + + + + + ${msg("Screenshots")} + + + ${this.qaStats.render({ + complete: ({ screenshotMatch }) => + this.renderMeter(qaRun.stats.found, screenshotMatch), + pending: () => this.renderMeter(), + initial: () => this.renderMeter(), + })} + + + + + ${msg("Text")} + + + ${this.qaStats.render({ + complete: ({ textMatch }) => + this.renderMeter(qaRun.stats.found, textMatch), + pending: () => this.renderMeter(), + initial: () => this.renderMeter(), + })} + + + + +
+
+
+ ${qaStatsThresholds.map( + (threshold) => html` +
+
+ ${threshold.lowerBoundary} +
+
${threshold.label}
+
+ `, + )} +
+
+
+ `; + } + + private renderMeter(pageCount?: number, barData?: QAStatsThreshold[]) { + if (pageCount === undefined || !barData) { + return html``; + } + + return html` + + ${barData.map((bar) => { + const threshold = qaStatsThresholds.find( + ({ lowerBoundary }) => bar.lowerBoundary === lowerBoundary, + ); + const idx = threshold ? qaStatsThresholds.indexOf(threshold) : -1; + + return html` + +
+ ${bar.lowerBoundary === "No data" + ? msg("No Data") + : threshold?.label} +
+ ${bar.lowerBoundary !== "No data" + ? html`${idx === 0 + ? `<${+qaStatsThresholds[idx + 1].lowerBoundary * 100}%` + : idx === qaStatsThresholds.length - 1 + ? `>=${threshold ? +threshold.lowerBoundary * 100 : 0}%` + : `${threshold ? +threshold.lowerBoundary * 100 : 0}-${+qaStatsThresholds[idx + 1].lowerBoundary * 100}%`} + match
` + : nothing} + ${formatNumber(bar.count)} ${pluralOf("pages", bar.count)} +
+
+
+ `; + })} +
`; } @@ -612,11 +760,7 @@ export class ArchivedItemDetailQA extends TailwindElement { name="chat-square-text-fill" class="text-blue-600" >`, - page.notes.length === 1 - ? msg(str`1 comment`) - : msg( - str`${page.notes.length.toLocaleString()} comments`, - ), + `${page.notes.length.toLocaleString()} ${pluralOf("comments", page.notes.length)}`, ) : html`${msg("None")}( `/orgs/${this.orgId}/crawls/${this.crawlId}/qa/${qaRunId}/replay.json`, @@ -722,7 +866,7 @@ export class ArchivedItemDetailQA extends TailwindElement { } } - async deleteQARun(id: string) { + private async deleteQARun(id: string) { try { await this.api.fetch( `/orgs/${this.orgId}/crawls/${this.crawlId}/qa/delete`, @@ -733,4 +877,26 @@ export class ArchivedItemDetailQA extends TailwindElement { console.error(e); } } + + private async getQAStats( + orgId: string, + crawlId: string, + qaRunId: string, + authState: Auth, + ) { + const query = queryString.stringify( + { + screenshotThresholds: [0.5, 0.9], + textThresholds: [0.5, 0.9], + }, + { + arrayFormat: "comma", + }, + ); + + return this.api.fetch( + `/orgs/${orgId}/crawls/${crawlId}/qa/${qaRunId}/stats?${query}`, + authState, + ); + } } diff --git a/frontend/src/utils/pluralize.ts b/frontend/src/utils/pluralize.ts new file mode 100644 index 00000000..e5bfc69f --- /dev/null +++ b/frontend/src/utils/pluralize.ts @@ -0,0 +1,63 @@ +import { msg } from "@lit/localize"; + +import { pluralize } from "./localization"; + +// Add to this as necessary! +const plurals = { + pages: { + zero: msg("pages", { + desc: 'plural form of "page" for zero pages', + id: "pages.plural.zero", + }), + one: msg("page", { + desc: 'singular form for "page"', + id: "pages.plural.one", + }), + two: msg("pages", { + desc: 'plural form of "page" for two pages', + id: "pages.plural.two", + }), + few: msg("pages", { + desc: 'plural form of "page" for few pages', + id: "pages.plural.few", + }), + many: msg("pages", { + desc: 'plural form of "page" for many pages', + id: "pages.plural.many", + }), + other: msg("pages", { + desc: 'plural form of "page" for multiple/other pages', + id: "pages.plural.other", + }), + }, + comments: { + zero: msg("comments", { + desc: 'plural form of "comment" for zero comments', + id: "comments.plural.zero", + }), + one: msg("comment", { + desc: 'singular form for "comment"', + id: "comments.plural.one", + }), + two: msg("comments", { + desc: 'plural form of "comment" for two comments', + id: "comments.plural.two", + }), + few: msg("comments", { + desc: 'plural form of "comment" for few comments', + id: "comments.plural.few", + }), + many: msg("comments", { + desc: 'plural form of "comment" for many comments', + id: "comments.plural.many", + }), + other: msg("comments", { + desc: 'plural form of "comment" for multiple/other comments', + id: "comments.plural.other", + }), + }, +}; + +export const pluralOf = (word: keyof typeof plurals, count: number) => { + return pluralize(count, plurals[word]); +}; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 207cefc6..b9e1d234 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -811,6 +811,11 @@ resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.2.tgz#d693d972974a354034454ec1317eb6afd0b00312" integrity sha512-jnOD+/+dSrfTWYfSXBXlo5l5f0q1UuJo3tkbMDCYA2lKUYq79jaxqtGEvnRoh049nt1vdo1+45RinipU6FGY2g== +"@lit-labs/ssr-dom-shim@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.2.0.tgz#353ce4a76c83fadec272ea5674ede767650762fd" + integrity sha512-yWJKmpGE6lUURKAaIltoPIE/wrbY3TEkqQt+X0m+7fQNnAv0keydnYvbiJFP1PnMhizmIWRWOG5KLhYyc/xl+g== + "@lit/localize-tools@^0.7.1": version "0.7.1" resolved "https://registry.yarnpkg.com/@lit/localize-tools/-/localize-tools-0.7.1.tgz#cb80af296d99c029c59cec57813ae8f11d43a777" @@ -847,6 +852,13 @@ dependencies: "@lit-labs/ssr-dom-shim" "^1.0.0" +"@lit/reactive-element@^1.0.0 || ^2.0.0": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-2.0.4.tgz#8f2ed950a848016383894a26180ff06c56ae001b" + integrity sha512-GFn91inaUa2oHLak8awSIigYz0cU0Payr1rcFsrkf5OJ5eSPxElyZfKh0f2p9FsTiZWXQdWGJeXZICEfXXYSXQ== + dependencies: + "@lit-labs/ssr-dom-shim" "^1.2.0" + "@lit/reactive-element@^2.0.0": version "2.0.2" resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-2.0.2.tgz#779ae9d265407daaf7737cb892df5ec2a86e22a0" @@ -854,6 +866,13 @@ dependencies: "@lit-labs/ssr-dom-shim" "^1.1.2" +"@lit/task@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@lit/task/-/task-1.0.0.tgz#61ae9ac6131368bbcf5f09ccade8037e6bb8705e" + integrity sha512-7jocGBh3yGlo3kKxQggZph2txK4X5GYNWp2FAsmV9u2spzUypwrzRzXe8I72icAb02B00+k2nlvxVcrQB6vyrw== + dependencies: + "@lit/reactive-element" "^1.0.0 || ^2.0.0" + "@mdn/browser-compat-data@^4.0.0": version "4.2.1" resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-4.2.1.tgz#1fead437f3957ceebe2e8c3f46beccdb9bc575b8"