Show QA meter while analysis is running (#1854)

Fixes #1846 

- Ensure meter auto-updates as new stats are ready
- Switch meter to new QA run when new analysis run is started
- Remove Files from QA meter (files and errors will be reported separately)

Co-authored-by: emma <hi@emma.cafe>
Co-authored-by: sua yoo <sua@webrecorder.org>
This commit is contained in:
Tessa Walsh 2024-06-12 12:32:01 -04:00 committed by GitHub
parent 2ffb37bd14
commit 8b0d1432af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 92 additions and 107 deletions

View File

@ -957,12 +957,11 @@ class CrawlOps(BaseCrawlOps):
thresholds: Dict[str, List[float]], thresholds: Dict[str, List[float]],
) -> QARunAggregateStatsOut: ) -> QARunAggregateStatsOut:
"""Get aggregate stats for QA run""" """Get aggregate stats for QA run"""
file_count = await self.page_ops.get_crawl_file_count(crawl_id)
screenshot_results = await self.page_ops.get_qa_run_aggregate_counts( screenshot_results = await self.page_ops.get_qa_run_aggregate_counts(
crawl_id, qa_run_id, thresholds, file_count, key="screenshotMatch" crawl_id, qa_run_id, thresholds, key="screenshotMatch"
) )
text_results = await self.page_ops.get_qa_run_aggregate_counts( text_results = await self.page_ops.get_qa_run_aggregate_counts(
crawl_id, qa_run_id, thresholds, file_count, key="textMatch" crawl_id, qa_run_id, thresholds, key="textMatch"
) )
return QARunAggregateStatsOut( return QARunAggregateStatsOut(
screenshotMatch=screenshot_results, screenshotMatch=screenshot_results,

View File

@ -548,7 +548,6 @@ class PageOps:
crawl_id: str, crawl_id: str,
qa_run_id: str, qa_run_id: str,
thresholds: Dict[str, List[float]], thresholds: Dict[str, List[float]],
file_count: int,
key: str = "screenshotMatch", key: str = "screenshotMatch",
): ):
"""Get counts for pages in QA run in buckets by score key based on thresholds""" """Get counts for pages in QA run in buckets by score key based on thresholds"""
@ -585,17 +584,11 @@ class PageOps:
return_data = [] return_data = []
for result in results: for result in results:
key = str(result.get("_id")) return_data.append(
if key == "No data": QARunBucketStats(
count = result.get("count", 0) - file_count lowerBoundary=str(result.get("_id")), count=result.get("count", 0)
return_data.append(QARunBucketStats(lowerBoundary=key, count=count))
else:
return_data.append(
QARunBucketStats(lowerBoundary=key, count=result.get("count", 0))
) )
)
# Add file count
return_data.append(QARunBucketStats(lowerBoundary="Files", count=file_count))
# Add missing boundaries to result and re-sort # Add missing boundaries to result and re-sort
for boundary in boundaries: for boundary in boundaries:

View File

@ -329,13 +329,11 @@ def test_qa_stats(
{"lowerBoundary": "0.0", "count": 0}, {"lowerBoundary": "0.0", "count": 0},
{"lowerBoundary": "0.7", "count": 0}, {"lowerBoundary": "0.7", "count": 0},
{"lowerBoundary": "0.9", "count": 1}, {"lowerBoundary": "0.9", "count": 1},
{"lowerBoundary": "Files", "count": 0},
] ]
assert data["textMatch"] == [ assert data["textMatch"] == [
{"lowerBoundary": "0.0", "count": 0}, {"lowerBoundary": "0.0", "count": 0},
{"lowerBoundary": "0.7", "count": 0}, {"lowerBoundary": "0.7", "count": 0},
{"lowerBoundary": "0.9", "count": 1}, {"lowerBoundary": "0.9", "count": 1},
{"lowerBoundary": "Files", "count": 0},
] ]
# Test we get expected results with explicit 0 boundary # Test we get expected results with explicit 0 boundary
@ -350,13 +348,11 @@ def test_qa_stats(
{"lowerBoundary": "0.0", "count": 0}, {"lowerBoundary": "0.0", "count": 0},
{"lowerBoundary": "0.7", "count": 0}, {"lowerBoundary": "0.7", "count": 0},
{"lowerBoundary": "0.9", "count": 1}, {"lowerBoundary": "0.9", "count": 1},
{"lowerBoundary": "Files", "count": 0},
] ]
assert data["textMatch"] == [ assert data["textMatch"] == [
{"lowerBoundary": "0.0", "count": 0}, {"lowerBoundary": "0.0", "count": 0},
{"lowerBoundary": "0.7", "count": 0}, {"lowerBoundary": "0.7", "count": 0},
{"lowerBoundary": "0.9", "count": 1}, {"lowerBoundary": "0.9", "count": 1},
{"lowerBoundary": "Files", "count": 0},
] ]
# Test that missing threshold values result in 422 HTTPException # Test that missing threshold values result in 422 HTTPException

View File

@ -30,6 +30,7 @@ export class MeterBar extends TailwindElement {
height: 1rem; height: 1rem;
background-color: var(--background-color, var(--sl-color-blue-500)); background-color: var(--background-color, var(--sl-color-blue-500));
min-width: 4px; min-width: 4px;
transition: 400ms width;
} }
`; `;
@ -151,6 +152,7 @@ export class Meter extends TailwindElement {
display: flex; display: flex;
border-radius: var(--sl-border-radius-medium); border-radius: var(--sl-border-radius-medium);
overflow: hidden; overflow: hidden;
transition: 400ms width;
} }
.labels { .labels {

View File

@ -39,7 +39,9 @@ export class QaRunDropdown extends TailwindElement {
slot="prefix" slot="prefix"
hoist hoist
></btrix-crawl-status> ></btrix-crawl-status>
${formatISODateString(selectedRun.finished)} ` ${selectedRun.finished
? formatISODateString(selectedRun.finished)
: msg("In progress")}`
: msg("Select a QA run")} : msg("Select a QA run")}
</sl-button> </sl-button>
<sl-menu> <sl-menu>
@ -52,7 +54,9 @@ export class QaRunDropdown extends TailwindElement {
?disabled=${isSelected} ?disabled=${isSelected}
?checked=${isSelected} ?checked=${isSelected}
> >
${formatISODateString(run.finished)} ${run.finished
? formatISODateString(run.finished)
: msg("In progress")}
<btrix-crawl-status <btrix-crawl-status
type="qa" type="qa"
hideLabel hideLabel

View File

@ -92,9 +92,6 @@ export class ArchivedItemDetail extends TailwindElement {
@state() @state()
private qaRunId?: string; private qaRunId?: string;
@state()
private lastFinishedQARunId?: string;
@state() @state()
private isQAActive = false; private isQAActive = false;
@ -187,25 +184,11 @@ export class ArchivedItemDetail extends TailwindElement {
(changedProperties.has("qaRuns") || (changedProperties.has("qaRuns") ||
changedProperties.has("mostRecentNonFailedQARun")) && changedProperties.has("mostRecentNonFailedQARun")) &&
this.qaRuns && this.qaRuns &&
this.mostRecentNonFailedQARun this.mostRecentNonFailedQARun?.id
) { ) {
const lastFinishedQARun = this.qaRuns.find(({ state }) => if (!this.qaRunId) {
finishedCrawlStates.includes(state), this.qaRunId = this.mostRecentNonFailedQARun.id;
);
const prevMostRecentNonFailedQARun =
changedProperties.get("mostRecentNonFailedQARun") ||
this.mostRecentNonFailedQARun;
const mostRecentNowFinished =
QA_RUNNING_STATES.includes(prevMostRecentNonFailedQARun.state) &&
finishedCrawlStates.includes(this.mostRecentNonFailedQARun.state);
// Update currently selected QA run if there is none,
// or if a QA run that was previously running is now finished:
if (lastFinishedQARun && (!this.qaRunId || mostRecentNowFinished)) {
this.qaRunId = lastFinishedQARun.id;
} }
// set last finished run
this.lastFinishedQARunId = lastFinishedQARun?.id;
} }
} }
@ -1025,10 +1008,8 @@ ${this.crawl?.description}
} }
private readonly renderQAHeader = (qaRuns: QARun[]) => { private readonly renderQAHeader = (qaRuns: QARun[]) => {
//const qaIsRunning = isActive(qaRuns[0]?.state);
//const qaIsAvailable = this.mostRecentNonFailedQARun && !qaIsRunning;
const qaIsRunning = this.isQAActive; const qaIsRunning = this.isQAActive;
const qaIsAvailable = !!this.lastFinishedQARunId; const qaIsAvailable = !!this.mostRecentNonFailedQARun;
const reviewLink = const reviewLink =
qaIsAvailable && this.qaRunId qaIsAvailable && this.qaRunId
@ -1345,13 +1326,14 @@ ${this.crawl?.description}
private async startQARun() { private async startQARun() {
try { try {
await this.api.fetch<{ started: string }>( const result = await this.api.fetch<{ started: string }>(
`/orgs/${this.orgId}/crawls/${this.crawlId}/qa/start`, `/orgs/${this.orgId}/crawls/${this.crawlId}/qa/start`,
this.authState!, this.authState!,
{ {
method: "POST", method: "POST",
}, },
); );
this.qaRunId = result.started;
void this.fetchQARuns(); void this.fetchQARuns();
@ -1457,6 +1439,10 @@ ${this.crawl?.description}
); );
if (this.isQAActive) { if (this.isQAActive) {
// Clear current timer, if it exists
if (this.timerId != null) {
this.stopPoll();
}
// Restart timer for next poll // Restart timer for next poll
this.timerId = window.setTimeout(() => { this.timerId = window.setTimeout(() => {
void this.fetchQARuns(); void this.fetchQARuns();

View File

@ -44,7 +44,7 @@ import { formatNumber, getLocale } from "@/utils/localization";
import { pluralOf } from "@/utils/pluralize"; import { pluralOf } from "@/utils/pluralize";
type QAStatsThreshold = { type QAStatsThreshold = {
lowerBoundary: `${number}` | "No data" | "Files"; lowerBoundary: `${number}` | "No data";
count: number; count: number;
}; };
type QAStats = Record<"screenshotMatch" | "textMatch", QAStatsThreshold[]>; type QAStats = Record<"screenshotMatch" | "textMatch", QAStatsThreshold[]>;
@ -65,11 +65,6 @@ const qaStatsThresholds = [
cssColor: "var(--sl-color-success-500)", cssColor: "var(--sl-color-success-500)",
label: msg("Good Match"), label: msg("Good Match"),
}, },
{
lowerBoundary: "Files",
cssColor: "var(--sl-color-neutral-500)",
label: msg("Identical Files"),
},
]; ];
const notApplicable = () => const notApplicable = () =>
@ -129,13 +124,28 @@ export class ArchivedItemDetailQA extends TailwindElement {
private pages?: APIPaginatedList<ArchivedItemPage>; private pages?: APIPaginatedList<ArchivedItemPage>;
private readonly qaStats = new Task(this, { private readonly qaStats = new Task(this, {
task: async ([orgId, crawlId, qaRunId, authState]) => { // mostRecentNonFailedQARun passed as arg for reactivity so that meter will auto-update
if (!qaRunId || !authState) throw new Error("Missing args"); // 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); const stats = await this.getQAStats(orgId, crawlId, qaRunId, authState);
return stats; return stats;
}, },
args: () => args: () =>
[this.orgId!, this.crawlId!, this.qaRunId, this.authState] as const, [
this.orgId!,
this.crawlId!,
this.qaRunId,
this.authState,
this.mostRecentNonFailedQARun,
] as const,
}); });
@state() @state()
@ -473,13 +483,6 @@ export class ArchivedItemDetailQA extends TailwindElement {
} }
return html` return html`
${isRunning
? html`<btrix-alert class="mb-3" variant="warning">
${msg(
"This crawl is being analyzed. You're currently viewing results from an older analysis run.",
)}
</btrix-alert>`
: nothing}
<btrix-card> <btrix-card>
<div slot="title" class="flex flex-wrap justify-between"> <div slot="title" class="flex flex-wrap justify-between">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1"> <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
@ -488,23 +491,23 @@ export class ArchivedItemDetailQA extends TailwindElement {
const finishedQARuns = qaRuns.filter(({ state }) => const finishedQARuns = qaRuns.filter(({ state }) =>
finishedCrawlStates.includes(state), finishedCrawlStates.includes(state),
); );
if (!finishedQARuns.length) {
return nothing;
}
const mostRecentSelected =
this.mostRecentNonFailedQARun &&
this.mostRecentNonFailedQARun.id === this.qaRunId;
const latestFinishedSelected = const latestFinishedSelected =
this.qaRunId === finishedQARuns[0].id; 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` return html`
<div> <div>
<sl-tooltip <sl-tooltip
content=${mostRecentSelected content=${mostRecentSelected
? msg( ? msg(
"Youre viewing the latest results from a finished analysis run.", "Youre viewing the latest analysis run results.",
) )
: msg( : msg(
"Youre viewing results from an older analysis run.", "Youre viewing results from an older analysis run.",
@ -522,7 +525,7 @@ export class ArchivedItemDetailQA extends TailwindElement {
</sl-tag> </sl-tag>
</sl-tooltip> </sl-tooltip>
<btrix-qa-run-dropdown <btrix-qa-run-dropdown
.items=${finishedQARuns} .items=${finishedAndRunningQARuns}
selectedId=${this.qaRunId || ""} selectedId=${this.qaRunId || ""}
@btrix-select=${(e: CustomEvent<SelectDetail>) => @btrix-select=${(e: CustomEvent<SelectDetail>) =>
(this.qaRunId = e.detail.item.id)} (this.qaRunId = e.detail.item.id)}
@ -566,12 +569,12 @@ export class ArchivedItemDetailQA extends TailwindElement {
${msg("Screenshots")} ${msg("Screenshots")}
</btrix-table-cell> </btrix-table-cell>
<btrix-table-cell class="p-0"> <btrix-table-cell class="p-0">
${this.qaStats.render({ ${this.qaStats.value
complete: ({ screenshotMatch }) => ? this.renderMeter(
this.renderMeter(qaRun.stats.found, screenshotMatch), qaRun.stats.found,
pending: () => this.renderMeter(), this.qaStats.value.screenshotMatch,
initial: () => this.renderMeter(), )
})} : this.renderMeter()}
</btrix-table-cell> </btrix-table-cell>
</btrix-table-row> </btrix-table-row>
<btrix-table-row> <btrix-table-row>
@ -579,12 +582,12 @@ export class ArchivedItemDetailQA extends TailwindElement {
${msg("Text")} ${msg("Text")}
</btrix-table-cell> </btrix-table-cell>
<btrix-table-cell class="p-0"> <btrix-table-cell class="p-0">
${this.qaStats.render({ ${this.qaStats.value
complete: ({ textMatch }) => ? this.renderMeter(
this.renderMeter(qaRun.stats.found, textMatch), qaRun.stats.found,
pending: () => this.renderMeter(), this.qaStats.value.textMatch,
initial: () => this.renderMeter(), )
})} : this.renderMeter()}
</btrix-table-cell> </btrix-table-cell>
</btrix-table-row> </btrix-table-row>
</btrix-table-body> </btrix-table-body>
@ -627,30 +630,32 @@ export class ArchivedItemDetailQA extends TailwindElement {
); );
const idx = threshold ? qaStatsThresholds.indexOf(threshold) : -1; const idx = threshold ? qaStatsThresholds.indexOf(threshold) : -1;
return html` return bar.count !== 0
<btrix-meter-bar ? html`
value=${(bar.count / pageCount) * 100} <btrix-meter-bar
style="--background-color: ${threshold?.cssColor}" value=${(bar.count / pageCount) * 100}
aria-label=${bar.lowerBoundary} style="--background-color: ${threshold?.cssColor}"
> aria-label=${bar.lowerBoundary}
<div class="text-center"> >
${bar.lowerBoundary === "No data" <div class="text-center">
? msg("No Data") ${bar.lowerBoundary === "No data"
: threshold?.label} ? msg("No Data")
<div class="text-xs opacity-80"> : threshold?.label}
${!["No data", "Files"].includes(bar.lowerBoundary) <div class="text-xs opacity-80">
? html`${idx === 0 ${bar.lowerBoundary !== "No data"
? `<${+qaStatsThresholds[idx + 1].lowerBoundary * 100}%` ? html`${idx === 0
: idx === qaStatsThresholds.length - 1 ? `<${+qaStatsThresholds[idx + 1].lowerBoundary * 100}%`
? `>=${threshold ? +threshold.lowerBoundary * 100 : 0}%` : idx === qaStatsThresholds.length - 1
: `${threshold ? +threshold.lowerBoundary * 100 : 0}-${+qaStatsThresholds[idx + 1].lowerBoundary * 100 || 100}%`} ? `>=${threshold ? +threshold.lowerBoundary * 100 : 0}%`
${msg("match")} <br />` : `${threshold ? +threshold.lowerBoundary * 100 : 0}-${+qaStatsThresholds[idx + 1].lowerBoundary * 100 || 100}%`}
: nothing} match <br />`
${formatNumber(bar.count)} ${pluralOf("pages", bar.count)} : nothing}
</div> ${formatNumber(bar.count)} ${pluralOf("pages", bar.count)}
</div> </div>
</btrix-meter-bar> </div>
`; </btrix-meter-bar>
`
: nothing;
})} })}
</btrix-meter> </btrix-meter>
`; `;