From a6435ae3d0e3c98b12dfdd91350a6259dbe02967 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Wed, 10 May 2023 17:57:38 -0700 Subject: [PATCH] Improve Workflow Detail tab and button UX (#840) - Adds primary action button next to "Actions" dropdown - Switches "Edit Workflow Settings" button to icon button - Redirects user to "Watch Crawl" tab when starting crawl - Now uses crawl ID from `data.started` in API `/run` response for more responsive UI - Keeps "Watch Crawl" tab navigation button in list but disable when crawl is not running - Also handles watch view when workflow is not running to cover navigational edge cases - Adds banner in "Crawls" list to direct users to the Watch Crawl when workflow is running - Shows notification when crawl is done to make redirect to Crawls tab smoother - Uses workflow scale when updating crawl scale - Removes "All" from "View: All Finished Crawls" on Finished Crawl page for wording consistency --- frontend/src/pages/org/crawls-list.ts | 2 +- frontend/src/pages/org/workflow-detail.ts | 395 +++++++++++++++------- 2 files changed, 265 insertions(+), 132 deletions(-) diff --git a/frontend/src/pages/org/crawls-list.ts b/frontend/src/pages/org/crawls-list.ts index 1a72681e..ae8ea09c 100644 --- a/frontend/src/pages/org/crawls-list.ts +++ b/frontend/src/pages/org/crawls-list.ts @@ -277,7 +277,7 @@ export class CrawlsList extends LiteElement { pill multiple max-tags-visible="1" - placeholder=${msg("All Finished Crawls")} + placeholder=${msg("Finished Crawls")} @sl-change=${async (e: CustomEvent) => { const value = (e.target as SlSelect).value as CrawlState[]; await this.updateComplete; diff --git a/frontend/src/pages/org/workflow-detail.ts b/frontend/src/pages/org/workflow-detail.ts index 8732ade7..b3474865 100644 --- a/frontend/src/pages/org/workflow-detail.ts +++ b/frontend/src/pages/org/workflow-detail.ts @@ -59,10 +59,16 @@ export class WorkflowDetail extends LiteElement { private workflow?: Workflow; @state() - private crawls?: Crawl[]; // Only inactive crawls + private crawls?: APIPaginatedList; // Only inactive crawls @state() - private currentCrawl?: Crawl; + private currentCrawlId: Workflow["currCrawlId"] = null; + + @state() + private currentCrawlStartTime: Workflow["currCrawlStartTime"] = null; + + @state() + private currentCrawlStats?: Crawl["stats"]; @state() private activePanel?: Tab; @@ -144,6 +150,13 @@ export class WorkflowDetail extends LiteElement { if (changedProperties.has("isEditing") && this.isEditing) { this.stopPoll(); } + if ( + changedProperties.get("currentCrawlId") && + !this.currentCrawlId && + this.activePanel === "watch" + ) { + this.handleCrawlRunEnd(); + } if (changedProperties.has("activePanel") && this.activePanel) { if (!this.isPanelHeaderVisible) { // Scroll panel header into view @@ -158,17 +171,6 @@ export class WorkflowDetail extends LiteElement { } } - updated(changedProperties: Map) { - const prevWorkflow = changedProperties.get("workflow"); - if ( - prevWorkflow?.currCrawlId && - !this.workflow?.currCrawlId && - this.activePanel === "watch" - ) { - this.goToTab(DEFAULT_SECTION, { replace: true }); - } - } - private getActivePanelFromHash = () => { const hashValue = window.location.hash.slice(1); if (SECTIONS.includes(hashValue as any)) { @@ -188,6 +190,47 @@ export class WorkflowDetail extends LiteElement { this.activePanel = tab; } + private async handleCrawlRunEnd() { + this.goToTab("artifacts", { replace: true }); + await this.fetchWorkflow(); + + let notifyOpts = { + message: msg("Crawl finished."), + variant: "info", + icon: "info-circle", + } as any; + // TODO consolidate with `CrawlStatus.getContent` + switch (this.workflow!.lastCrawlState) { + case "complete": + notifyOpts = { + message: msg("Crawl complete."), + variant: "success", + icon: "check-circle", + }; + break; + case "canceled": + notifyOpts = { + message: msg("Crawl canceled."), + variant: "danger", + icon: "x-octagon", + }; + break; + case "failed": + notifyOpts = { + message: msg("Crawl failed."), + variant: "danger", + icon: "exclamation-triangle", + }; + break; + default: + break; + } + this.notify({ + ...notifyOpts, + duration: 8000, + }); + } + private async fetchWorkflow() { this.stopPoll(); this.isLoading = true; @@ -195,8 +238,10 @@ export class WorkflowDetail extends LiteElement { try { this.getWorkflowPromise = this.getWorkflow(); this.workflow = await this.getWorkflowPromise; - if (this.workflow.currCrawlId) { - this.fetchCurrentCrawl(); + this.currentCrawlId = this.workflow.currCrawlId; + this.currentCrawlStartTime = this.workflow.currCrawlStartTime; + if (this.currentCrawlId) { + this.fetchCurrentCrawlStats(); } } catch (e: any) { this.notify({ @@ -257,7 +302,7 @@ export class WorkflowDetail extends LiteElement {
${when( this.isCrawler && this.workflow && !this.workflow.inactive, - this.renderMenu + this.renderActions )}
@@ -372,19 +417,21 @@ export class WorkflowDetail extends LiteElement { - ${when(this.workflow?.currCrawlId, () => this.renderTab("watch"))} - ${this.renderTab("artifacts")} ${this.renderTab("settings")} + ${this.renderTab("artifacts")} + ${this.renderTab("watch", { disabled: !this.currentCrawlId })} + ${this.renderTab("settings")} ${this.renderArtifacts()} ${when( - this.activePanel === "watch", - () => html`
- ${this.renderCurrentCrawl()} -
- ${this.renderWatchCrawl()}` + >${when(this.activePanel === "watch", () => + this.currentCrawlId + ? html`
+ ${this.renderCurrentCrawl()} +
+ ${this.renderWatchCrawl()}` + : this.renderInactiveWatchCrawl() )}
@@ -397,82 +444,65 @@ export class WorkflowDetail extends LiteElement { if (!this.activePanel) return; if (this.activePanel === "artifacts") { return html`

- ${this.workflow?.crawlCount === 1 - ? msg(str`${this.workflow?.crawlCount} Crawl`) - : msg(str`${this.workflow?.crawlCount} Crawls`)} -

- this.runNow()} - ?disabled=${this.workflow?.currCrawlId} - > - - ${msg("Run")} - `; + ${this.tabLabels[this.activePanel]} + ${when( + this.crawls, + () => + html` + (${this.crawls!.total.toLocaleString()}${this.currentCrawlId + ? html` + 1` + : ""}) + ` + )} + `; } if (this.activePanel === "settings") { return html`

${this.tabLabels[this.activePanel]}

- this.navTo( `/orgs/${this.workflow?.oid}/workflows/crawl/${this.workflow?.id}?edit` )} > - - ${msg("Edit")} - `; + `; } if (this.activePanel === "watch") { return html`

${this.tabLabels[this.activePanel]}

- - (this.openDialogName = "scale")} - > - - ${msg("Edit Instances")} - - (this.openDialogName = "stop")} - ?disabled=${!this.workflow?.currCrawlId || - this.isCancelingOrStoppingCrawl || - this.workflow?.currCrawlStopping} - > - - ${msg("Stop")} - - (this.openDialogName = "cancel")} - ?disabled=${!this.workflow?.currCrawlId || - this.isCancelingOrStoppingCrawl} - > - - ${msg("Cancel")} - - `; + (this.openDialogName = "scale")} + > + + ${msg("Edit Instances")} + `; } return html`

${this.tabLabels[this.activePanel]}

`; } - private renderTab(tabName: Tab) { + private renderTab(tabName: Tab, { disabled = false } = {}) { const isActive = tabName === this.activePanel; + let className = "text-neutral-600 hover:bg-neutral-50"; + if (isActive) { + className = "text-blue-600 bg-blue-50 shadow-sm"; + } else if (disabled) { + className = "text-neutral-300 cursor-not-allowed"; + } return html` { + if (disabled) e.preventDefault(); + }} > ${this.tabLabels[tabName]} @@ -504,18 +534,60 @@ export class WorkflowDetail extends LiteElement { )} `; - private renderMenu = () => { + private renderActions = () => { if (!this.workflow) return; const workflow = this.workflow; return html` + ${when( + this.currentCrawlId, + () => html` + + (this.openDialogName = "stop")} + ?disabled=${!this.currentCrawlId || + this.isCancelingOrStoppingCrawl || + this.workflow?.currCrawlStopping} + > + + ${msg("Stop")} + + (this.openDialogName = "cancel")} + ?disabled=${!this.currentCrawlId || + this.isCancelingOrStoppingCrawl} + > + + ${msg("Cancel")} + + + `, + () => html` + this.runNow()} + > + + ${msg("Run Crawl")} + + ` + )} + ${msg("Actions")} ${when( - workflow.currCrawlId, + this.currentCrawlId, // HACK shoelace doesn't current have a way to override non-hover // color without resetting the --sl-color-neutral-700 variable () => html` @@ -583,7 +655,7 @@ export class WorkflowDetail extends LiteElement { ${msg("Duplicate Workflow")} - ${when(!workflow.currCrawlId, () => { + ${when(!this.currentCrawlId, () => { const shouldDeactivate = workflow.crawlCount && !workflow.inactive; return html` @@ -699,7 +771,9 @@ export class WorkflowDetail extends LiteElement { private renderArtifacts() { return html`
-
+
${msg("View:")}
{ const value = (e.target as SlSelect).value as CrawlState[]; await this.updateComplete; @@ -725,6 +799,22 @@ export class WorkflowDetail extends LiteElement {
+ ${when( + this.currentCrawlId, + () => html`
+ + ${msg( + html`Crawl is currently running. + Watch Crawl Progress` + )} + +
` + )} + @@ -732,8 +822,8 @@ export class WorkflowDetail extends LiteElement { ${when( this.crawls, () => - this.crawls!.map( - (crawl) => html` + this.crawls!.items.map( + (crawl: Crawl) => html` ${when( - this.crawls && !this.crawls.length, + this.crawls && !this.crawls.items.length, () => html`

- ${this.workflow?.crawlCount + ${this.crawls?.total ? msg("No matching crawls found.") : msg("No crawls yet.")}

@@ -787,37 +877,40 @@ export class WorkflowDetail extends LiteElement { }; private renderCurrentCrawl = () => { - const crawl = this.currentCrawl; const skeleton = html``; return html`
${this.renderDetailItem(msg("Pages Crawled"), () => - crawl + this.currentCrawlStats ? msg( str`${this.numberFormatter.format( - +(crawl.stats?.done || 0) - )} / ${this.numberFormatter.format(+(crawl.stats?.found || 0))}` + +(this.currentCrawlStats.done || 0) + )} / ${this.numberFormatter.format( + +(this.currentCrawlStats.found || 0) + )}` ) - : skeleton + : html`` )} ${this.renderDetailItem(msg("Run Duration"), () => - crawl + this.currentCrawlStartTime ? RelativeDuration.humanize( - new Date().valueOf() - new Date(`${crawl.started}Z`).valueOf() + new Date().valueOf() - + new Date(`${this.currentCrawlStartTime}Z`).valueOf() ) : skeleton )} - ${this.renderDetailItem( - msg("Crawl Size"), - () => html`` + ${this.renderDetailItem(msg("Crawl Size"), () => + this.workflow + ? html`` + : skeleton )} ${this.renderDetailItem( msg("Crawler Instances"), - () => (crawl ? crawl.scale : skeleton), + () => (this.workflow ? this.workflow.scale : skeleton), true )}
@@ -858,14 +951,14 @@ export class WorkflowDetail extends LiteElement { ` : this.renderInactiveCrawlMessage()} ${when( - this.currentCrawl && isRunning, + isRunning, () => html`
@@ -885,6 +978,46 @@ export class WorkflowDetail extends LiteElement { `; }; + private renderInactiveWatchCrawl() { + return html` +
+

+ ${msg("Crawl is not currently running.")} +

+
+ ${when( + this.workflow?.lastCrawlId, + () => html` + + + ${msg("Replay Latest Crawl")} + ` + )} + + this.runNow()}> + + ${msg("Run Crawl")} + +
+
+ `; + } + private renderInactiveCrawlMessage() { return html`
@@ -910,11 +1043,11 @@ export class WorkflowDetail extends LiteElement { ${when( - this.workflow?.currCrawlId, + this.currentCrawlId, () => html` ` @@ -931,7 +1064,7 @@ export class WorkflowDetail extends LiteElement { ${this.workflow && this.isDialogVisible ? html` { + private async getCrawls(): Promise { const query = queryString.stringify( { state: this.filterBy.state || inactiveCrawlStates, @@ -1096,14 +1229,16 @@ export class WorkflowDetail extends LiteElement { this.authState! ); - return data.items; + return data; } - private async fetchCurrentCrawl() { - if (!this.workflow?.currCrawlId) return; + private async fetchCurrentCrawlStats() { + if (!this.currentCrawlId) return; try { - this.currentCrawl = await this.getCrawl(this.workflow.currCrawlId); + // TODO see if API can pass stats in GET workflow + const { stats } = await this.getCrawl(this.currentCrawlId); + this.currentCrawlStats = stats; } catch (e) { // TODO handle error console.debug(e); @@ -1215,13 +1350,13 @@ export class WorkflowDetail extends LiteElement { } private async cancel() { - if (!this.workflow?.currCrawlId) return; + if (!this.currentCrawlId) return; this.isCancelingOrStoppingCrawl = true; try { const data = await this.apiFetch( - `/orgs/${this.orgId}/crawls/${this.workflow.currCrawlId}/cancel`, + `/orgs/${this.orgId}/crawls/${this.currentCrawlId}/cancel`, this.authState!, { method: "POST", @@ -1244,13 +1379,13 @@ export class WorkflowDetail extends LiteElement { } private async stop() { - if (!this.workflow?.currCrawlId) return; + if (!this.currentCrawlId) return; this.isCancelingOrStoppingCrawl = true; try { const data = await this.apiFetch( - `/orgs/${this.orgId}/crawls/${this.workflow.currCrawlId}/stop`, + `/orgs/${this.orgId}/crawls/${this.currentCrawlId}/stop`, this.authState!, { method: "POST", @@ -1281,19 +1416,14 @@ export class WorkflowDetail extends LiteElement { method: "POST", } ); + this.currentCrawlId = data.started; + // remove 'Z' from timestamp to match API response + this.currentCrawlStartTime = new Date().toISOString().slice(0, -1); this.fetchWorkflow(); + this.goToTab("watch"); this.notify({ - message: msg( - html`Started crawl from ${this.renderName()}. -
- Watch crawl` - ), + message: msg("Starting crawl."), variant: "success", icon: "check2-circle", duration: 8000, @@ -1320,7 +1450,10 @@ export class WorkflowDetail extends LiteElement { } ); - this.crawls = this.crawls!.filter((c) => c.id !== crawl.id); + this.crawls = { + ...this.crawls!, + items: this.crawls!.items.filter((c) => c.id !== crawl.id), + }; this.notify({ message: msg(`Successfully deleted crawl`), variant: "success",