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,
);
}
}