import { localized, msg, str } from "@lit/localize"; import { Task } from "@lit/task"; import type { SlChangeEvent, SlSelect, SlShowEvent, } from "@shoelace-style/shoelace"; import { css, html, nothing, type PropertyValues, type TemplateResult, } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { when } from "lit/directives/when.js"; import queryString from "query-string"; import { QA_RUNNING_STATES } from "../archived-item-detail"; import { TailwindElement } from "@/classes/TailwindElement"; import { type Dialog } from "@/components/ui/dialog"; import type { MenuItemLink } from "@/components/ui/menu-item-link"; import type { OverflowDropdown } from "@/components/ui/overflow-dropdown"; import type { PageChangeEvent } from "@/components/ui/pagination"; import { APIController } from "@/controllers/api"; 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"; import type { SelectDetail } from "@/features/qa/qa-run-dropdown"; import type { APIPaginatedList, APIPaginationQuery, APISortQuery, } from "@/types/api"; import { type ArchivedItem, type ArchivedItemPage } from "@/types/crawler"; import type { QARun } from "@/types/qa"; import { type Auth, type AuthState } from "@/utils/AuthService"; import { finishedCrawlStates } from "@/utils/crawler"; import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter"; 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")}`; function statusWithIcon( icon: TemplateResult<1>, label: string | TemplateResult<1>, ) { return html`
${icon}${label}
`; } /** * @fires btrix-qa-runs-update */ @localized() @customElement("btrix-archived-item-detail-qa") export class ArchivedItemDetailQA extends TailwindElement { static styles = css` btrix-table { --btrix-cell-padding-top: var(--sl-spacing-x-small); --btrix-cell-padding-bottom: var(--sl-spacing-x-small); --btrix-cell-padding-left: var(--sl-spacing-small); --btrix-cell-padding-right: var(--sl-spacing-small); } `; @property({ type: Object, attribute: false }) authState?: AuthState; @property({ type: String, attribute: false }) orgId?: string; @property({ type: String, attribute: false }) crawlId?: string; @property({ type: String, attribute: false }) itemType: ArchivedItem["type"] = "crawl"; @property({ type: Object, attribute: false }) crawl?: ArchivedItem; @property({ type: String, attribute: false }) qaRunId?: string; @property({ type: Array, attribute: false }) qaRuns?: QARun[]; @property({ attribute: false }) mostRecentNonFailedQARun?: QARun; @state() private pages?: APIPaginatedList; private readonly qaStats = new Task(this, { // mostRecentNonFailedQARun passed as arg for reactivity so that meter will auto-update // like progress bar as the analysis run finishes new pages task: async ([ orgId, crawlId, qaRunId, authState, mostRecentNonFailedQARun, ]) => { if (!qaRunId || !authState || !mostRecentNonFailedQARun) 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, this.mostRecentNonFailedQARun, ] as const, }); @state() private deleting: string | null = null; @query("#qaPagesSortBySelect") private readonly qaPagesSortBySelect?: SlSelect | null; @query("#deleteQARunDialog") private readonly deleteQADialog?: Dialog | null; private readonly api = new APIController(this); private readonly navigate = new NavigateController(this); private readonly notify = new NotifyController(this); willUpdate(changedProperties: PropertyValues) { if (changedProperties.has("crawlId") && this.crawlId) { void this.fetchPages(); } } render() { return html`
${when( this.qaRuns, () => this.mostRecentNonFailedQARun ? html`` : statusWithIcon( html``, html` ${msg("Not Analyzed")} `, ), this.renderLoadingDetail, )} ${this.mostRecentNonFailedQARun?.state === "running" ? html` ` : ""} ${when( this.crawl, (crawl) => html``, this.renderLoadingDetail, )} ${when( this.qaRuns, () => this.mostRecentNonFailedQARun && this.crawl?.qaCrawlExecSeconds ? humanizeExecutionSeconds(this.crawl.qaCrawlExecSeconds) : notApplicable(), this.renderLoadingDetail, )}
${this.renderDeleteConfirmDialog()} ${msg("Pages")} ${msg("Analysis Runs")} ${when(this.mostRecentNonFailedQARun && this.qaRuns, (qaRuns) => this.renderAnalysis(qaRuns), )}

${msg("Pages")} (${( this.pages?.total ?? 0 ).toLocaleString()})

${this.renderPageListControls()} ${this.renderPageList()}
${msg("Status")} ${msg("Started")} ${msg("Finished")} ${msg("Started by")} ${msg("Row actions")} ${when(this.qaRuns, this.renderQARunRows)}
`; } private readonly renderQARunRows = (qaRuns: QARun[]) => { if (!qaRuns.length) { return html`
${msg("No analysis runs, yet")}
`; } return qaRuns.map( (run, idx) => html` 0 ? "border-t" : ""}> ${run.finished ? html` ` : notApplicable()} ${run.userName}
{ const dropdown = e.currentTarget as OverflowDropdown; const downloadLink = dropdown.querySelector( "btrix-menu-item-link", ); if (!downloadLink) { console.debug("no download link"); return; } downloadLink.loading = true; const file = await this.getQARunDownloadLink(run.id); if (file) { downloadLink.disabled = false; downloadLink.href = file.path; } else { downloadLink.disabled = true; } downloadLink.loading = false; }} > ${run.state === "canceled" ? nothing : html` ${msg("Download Analysis Run")} `} { this.deleting = run.id; void this.deleteQADialog?.show(); }} style="--sl-color-neutral-700: var(--danger)" > ${msg("Delete Analysis Run")}
`, ); }; private readonly renderDeleteConfirmDialog = () => { const runToBeDeleted = this.qaRuns?.find((run) => run.id === this.deleting); return html` ${msg( "All of the data included in this analysis run will be deleted.", )} ${runToBeDeleted && html`
${msg( str`This analysis run includes data for ${runToBeDeleted.stats.done} ${pluralOf("pages", runToBeDeleted.stats.done)} and was started on `, )} ${msg("by")} ${runToBeDeleted.userName}.
void this.deleteQADialog?.hide()} > ${msg("Cancel")} { await this.deleteQARun(runToBeDeleted.id); this.dispatchEvent(new CustomEvent("btrix-qa-runs-update")); this.deleting = null; void this.deleteQADialog?.hide(); }} >${msg("Delete Analysis Run")}
`}
`; }; private readonly renderLoadingDetail = () => html`
`; 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`
${msg("Page Match Analysis")} ${when(this.qaRuns, (qaRuns) => { const finishedQARuns = qaRuns.filter(({ state }) => finishedCrawlStates.includes(state), ); const latestFinishedSelected = this.qaRunId === finishedQARuns[0]?.id; const finishedAndRunningQARuns = qaRuns.filter( ({ state }) => finishedCrawlStates.includes(state) || QA_RUNNING_STATES.includes(state), ); const mostRecentSelected = this.qaRunId === finishedAndRunningQARuns[0]?.id; return html`
${mostRecentSelected ? msg("Current") : latestFinishedSelected ? msg("Last Finished") : msg("Outdated")} ) => (this.qaRunId = e.detail.item.id)} >
`; })}
${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.value ? this.renderMeter( qaRun.stats.found, this.qaStats.value.screenshotMatch, ) : this.renderMeter()} ${msg("Text")} ${this.qaStats.value ? this.renderMeter( qaRun.stats.found, this.qaStats.value.textMatch, ) : 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 bar.count !== 0 ? 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 || 100}%`} match
` : nothing} ${formatNumber(bar.count)} ${pluralOf("pages", bar.count)}
` : nothing; })}
`; } private renderPageListControls() { return html`
{ const { value } = e.target as SlSelect; const [field, direction] = ( Array.isArray(value) ? value[0] : value ).split("."); void this.fetchPages({ sortBy: field, sortDirection: +direction, page: 1, }); }} > ${msg("Title")} ${msg("URL")} ${msg("Most Comments")} ${msg("Recently Approved")} ${msg("Not Approved")}
`; } private renderPageList() { const pageTitle = (page: ArchivedItemPage) => html`
${page.title || html`${msg("No page title")}`}
${page.url}
`; return html` ${msg("Page")} ${msg("Approval")} ${msg("Comments")} ${this.pages?.items.map( (page, idx) => html` ${this.qaRunId ? html` ${pageTitle(page)} ` : pageTitle(page)} ${this.renderApprovalStatus(page)} ${page.notes?.length ? html`
${msg("Newest comment:")}
${page.notes[page.notes.length - 1].text}
${statusWithIcon( html``, `${page.notes.length.toLocaleString()} ${pluralOf("comments", page.notes.length)}`, )}
` : html` ${msg("None")} `}
`, )}
${when(this.pages, (pages) => pages.total > pages.pageSize ? html`
{ void this.fetchPages({ page: e.detail.page, }); }} >
` : nothing, )} `; } private renderApprovalStatus(page: ArchivedItemPage) { const approvalStatus = pageApproval.approvalFromPage(page); const status = approvalStatus === "commentOnly" ? null : approvalStatus; const icon = iconForPageReview(status); const label = pageApproval.labelFor(status) ?? html`${msg("None")}`; return statusWithIcon(icon, label); } async fetchPages(params?: APIPaginationQuery & APISortQuery): Promise { try { await this.updateComplete; let sortBy = params?.sortBy; let sortDirection = params?.sortDirection; if (!sortBy && this.qaPagesSortBySelect?.value[0]) { const value = this.qaPagesSortBySelect.value; if (value) { const [field, direction] = ( Array.isArray(value) ? value[0] : value ).split("."); sortBy = field; sortDirection = +direction; } } this.pages = await this.getPages({ page: params?.page ?? this.pages?.page ?? 1, pageSize: params?.pageSize ?? this.pages?.pageSize ?? 10, sortBy, sortDirection, }); } catch { this.notify.toast({ message: msg("Sorry, couldn't retrieve archived item at this time."), variant: "danger", icon: "exclamation-octagon", }); } } private async getPages( params?: APIPaginationQuery & APISortQuery & { reviewed?: boolean }, ): Promise> { const query = queryString.stringify( { ...params, }, { arrayFormat: "comma", }, ); return this.api.fetch>( `/orgs/${this.orgId}/crawls/${this.crawlId}/pages?${query}`, this.authState!, ); } private async getQARunDownloadLink(qaRunId: string) { try { const { resources } = await this.api.fetch( `/orgs/${this.orgId}/crawls/${this.crawlId}/qa/${qaRunId}/replay.json`, this.authState!, ); // TODO handle more than one file return resources?.[0]; } catch (e) { console.debug(e); } } private async deleteQARun(id: string) { try { await this.api.fetch( `/orgs/${this.orgId}/crawls/${this.crawlId}/qa/delete`, this.authState!, { method: "POST", body: JSON.stringify({ qa_run_ids: [id] }) }, ); } catch (e) { 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, ); } }