import type { TemplateResult } from "lit"; import { state, property, customElement } from "lit/decorators.js"; import { when } from "lit/directives/when.js"; import { until } from "lit/directives/until.js"; import { msg, localized, str } from "@lit/localize"; import queryString from "query-string"; import { CopyButton } from "@/components/ui/copy-button"; import { CrawlStatus } from "@/features/archived-items/crawl-status"; import { RelativeDuration } from "@/components/ui/relative-duration"; import type { AuthState } from "@/utils/AuthService"; import LiteElement, { html } from "@/utils/LiteElement"; import type { Crawl, CrawlState, Workflow, WorkflowParams, Seed, } from "./types"; import { humanizeSchedule } from "@/utils/cron"; import type { APIPaginatedList } from "@/types/api"; import { inactiveCrawlStates, isActive } from "@/utils/crawler"; import type { SlSelect } from "@shoelace-style/shoelace"; import type { PageChangeEvent } from "@/components/ui/pagination"; import { ExclusionEditor } from "@/features/crawl-workflows/exclusion-editor"; import type { CrawlLog } from "@/features/archived-items/crawl-logs"; const SECTIONS = ["crawls", "watch", "settings", "logs"] as const; type Tab = (typeof SECTIONS)[number]; const DEFAULT_SECTION: Tab = "crawls"; const POLL_INTERVAL_SECONDS = 10; const LOGS_PAGE_SIZE = 50; /** * Usage: * ```ts * * ``` */ @localized() @customElement("btrix-workflow-detail") export class WorkflowDetail extends LiteElement { @property({ type: Object }) authState!: AuthState; @property({ type: String }) orgId!: string; @property({ type: Boolean }) orgStorageQuotaReached = false; @property({ type: Boolean }) orgExecutionMinutesQuotaReached = false; @property({ type: String }) workflowId!: string; @property({ type: Boolean }) isEditing: boolean = false; @property({ type: Boolean }) isCrawler!: boolean; @property({ type: String }) openDialogName?: "scale" | "exclusions" | "cancel" | "stop" | "delete"; @property({ type: String }) initialActivePanel?: Tab; @state() private workflow?: Workflow; @state() private seeds?: APIPaginatedList; @state() private crawls?: APIPaginatedList; // Only inactive crawls @state() private logs?: APIPaginatedList; @state() private lastCrawlId: Workflow["lastCrawlId"] = null; @state() private lastCrawlStartTime: Workflow["lastCrawlStartTime"] = null; @state() private lastCrawlStats?: Crawl["stats"]; @state() private activePanel: Tab = SECTIONS[0]; @state() private isLoading: boolean = false; @state() private isSubmittingUpdate: boolean = false; @state() private isDialogVisible: boolean = false; @state() private isCancelingOrStoppingCrawl: boolean = false; @state() private crawlToDelete: Crawl | null = null; @state() private filterBy: Partial> = {}; // TODO localize private numberFormatter = new Intl.NumberFormat(undefined, { // notation: "compact", }); private dateFormatter = new Intl.DateTimeFormat(undefined, { year: "numeric", month: "numeric", day: "numeric", }); private timerId?: number; private isPanelHeaderVisible?: boolean; private getWorkflowPromise?: Promise; private getSeedsPromise?: Promise>; private readonly tabLabels: Record = { crawls: msg("Crawls"), watch: msg("Watch Crawl"), logs: msg("Error Logs"), settings: msg("Workflow Settings"), }; connectedCallback(): void { // Set initial active section and dialog based on URL #hash value if (this.initialActivePanel) { this.activePanel = this.initialActivePanel; } else { this.getActivePanelFromHash(); } super.connectedCallback(); window.addEventListener("hashchange", this.getActivePanelFromHash); } disconnectedCallback(): void { this.stopPoll(); super.disconnectedCallback(); window.removeEventListener("hashchange", this.getActivePanelFromHash); } firstUpdated() { if ( this.openDialogName && (this.openDialogName === "scale" || this.openDialogName === "exclusions") ) { this.showDialog(); } } willUpdate(changedProperties: Map) { if ( (changedProperties.has("workflowId") && this.workflowId) || (changedProperties.get("isEditing") === true && this.isEditing === false) ) { this.fetchWorkflow(); this.fetchSeeds(); } if (changedProperties.has("isEditing")) { if (this.isEditing) { this.stopPoll(); } else { this.getActivePanelFromHash(); } } if ( !this.isEditing && changedProperties.has("activePanel") && this.activePanel ) { if (!this.isPanelHeaderVisible) { // Scroll panel header into view this.querySelector("btrix-tab-list")?.scrollIntoView({ behavior: "smooth", }); } if (this.activePanel === "crawls") { this.fetchCrawls(); } } } private getActivePanelFromHash = async () => { await this.updateComplete; if (this.isEditing) return; const hashValue = window.location.hash.slice(1); if (SECTIONS.includes(hashValue as any)) { this.activePanel = hashValue as Tab; } else { this.goToTab(DEFAULT_SECTION, { replace: true }); } }; private goToTab(tab: Tab, { replace = false } = {}) { const path = `${window.location.href.split("#")[0]}#${tab}`; if (replace) { window.history.replaceState(null, "", path); } else { window.history.pushState(null, "", path); } this.activePanel = tab; } private async fetchWorkflow() { this.stopPoll(); this.isLoading = true; try { const prevLastCrawlId = this.lastCrawlId; this.getWorkflowPromise = this.getWorkflow(); this.workflow = await this.getWorkflowPromise; this.lastCrawlId = this.workflow.lastCrawlId; this.lastCrawlStartTime = this.workflow.lastCrawlStartTime; if (this.lastCrawlId) { if (this.workflow.isCrawlRunning) { this.fetchCurrentCrawlStats(); this.fetchCrawlLogs(); } else if (this.lastCrawlId !== prevLastCrawlId) { this.logs = undefined; this.fetchCrawlLogs(); } } // TODO: Check if storage quota has been exceeded here by running // crawl?? } catch (e: any) { this.notify({ message: e.statusCode === 404 ? msg("Workflow not found.") : msg("Sorry, couldn't retrieve Workflow at this time."), variant: "danger", icon: "exclamation-octagon", }); } this.isLoading = false; if (!this.isEditing) { // Restart timer for next poll this.timerId = window.setTimeout(() => { this.fetchWorkflow(); }, 1000 * POLL_INTERVAL_SECONDS); } } render() { if (this.isEditing && this.isCrawler) { return html`
${when(this.workflow, this.renderEditor)}
`; } return html`
${this.renderHeader()}

${this.renderName()} ${when( this.workflow?.inactive, () => html` ${msg("Inactive")} ` )}

${when( this.isCrawler && this.workflow && !this.workflow.inactive, this.renderActions )}
${this.renderDetails()}
${when(this.workflow, this.renderTabList, this.renderLoading)}
(this.openDialogName = undefined)} @sl-show=${this.showDialog} @sl-after-hide=${() => (this.isDialogVisible = false)} > ${msg( "Pages crawled so far will be saved and marked as incomplete. Are you sure you want to stop crawling?" )}
(this.openDialogName = undefined)} >${msg("Keep Crawling")} { await this.stop(); this.openDialogName = undefined; }} >${msg("Stop Crawling")}
(this.openDialogName = undefined)} @sl-show=${this.showDialog} @sl-after-hide=${() => (this.isDialogVisible = false)} > ${msg( "Canceling will discard all pages crawled. Are you sure you want to discard them?" )}
(this.openDialogName = undefined)} >${msg("Keep Crawling")} { await this.cancel(); this.openDialogName = undefined; }} >${msg("Cancel & Discard Crawl")}
(this.openDialogName = undefined)} @sl-show=${this.showDialog} @sl-after-hide=${() => (this.isDialogVisible = false)} > ${msg( "All files and logs associated with this crawl will also be deleted, and the crawl will be removed from any Collection it is a part of." )}
(this.openDialogName = undefined)} >${msg("Cancel")} { this.openDialogName = undefined; if (this.crawlToDelete) { await this.deleteCrawl(this.crawlToDelete); } }} >${msg("Delete Crawl")}
`; } private renderHeader(workflowId?: string) { return html` `; } private renderTabList = () => html` (this.isPanelHeaderVisible = detail.entry.isIntersecting)} >
${this.renderPanelHeader()}
${this.renderTab("crawls")} ${this.renderTab("watch")} ${this.renderTab("logs")} ${this.renderTab("settings")} ${this.renderCrawls()} ${until( this.getWorkflowPromise?.then( () => html` ${when(this.activePanel === "watch", () => this.workflow?.isCrawlRunning ? html`
${this.renderCurrentCrawl()}
${this.renderWatchCrawl()}` : this.renderInactiveWatchCrawl() )} ` ) )}
${this.renderLogs()} ${this.renderSettings()}
`; private renderPanelHeader() { if (!this.activePanel) return; if (this.activePanel === "crawls") { return html`

${this.tabLabels[this.activePanel]} ${when( this.crawls, () => html` (${this.crawls!.total.toLocaleString()}${this.workflow ?.isCrawlRunning ? html` + 1` : ""}) ` )}

`; } if (this.activePanel === "settings" && this.isCrawler == true) { return html`

${this.tabLabels[this.activePanel]}

this.navTo( `/orgs/${this.appState.orgSlug}/workflows/crawl/${this.workflow?.id}?edit` )} > `; } if (this.activePanel === "watch" && this.isCrawler == true) { return html`

${this.tabLabels[this.activePanel]}

(this.openDialogName = "scale")} > ${msg("Edit Crawler Instances")} `; } if (this.activePanel === "logs") { const authToken = this.authState!.headers.Authorization.split(" ")[1]; const isDownloadEnabled = Boolean( this.logs?.total && this.workflow?.lastCrawlId && !this.workflow.isCrawlRunning ); return html`

${this.tabLabels[this.activePanel]}

${msg("Download Logs")} `; } return html`

${this.tabLabels[this.activePanel]}

`; } 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]} `; } private renderEditor = () => html` ${this.renderHeader(this.workflow!.id)}

${this.renderName()}

${when( !this.isLoading && this.seeds, () => html` this.navTo( `${this.orgBasePath}/workflows/crawl/${this.workflow!.id}` )} > `, this.renderLoading )} `; private renderActions = () => { if (!this.workflow) return; const workflow = this.workflow; return html` ${when( this.workflow?.isCrawlRunning, () => html` (this.openDialogName = "stop")} ?disabled=${!this.lastCrawlId || this.isCancelingOrStoppingCrawl || this.workflow?.lastCrawlStopping} > ${msg("Stop")} (this.openDialogName = "cancel")} ?disabled=${!this.lastCrawlId || this.isCancelingOrStoppingCrawl} > ${msg("Cancel")} `, () => html` this.runNow()} > ${msg("Run Crawl")} ` )} ${msg("Actions")} ${when( this.workflow?.isCrawlRunning, // HACK shoelace doesn't current have a way to override non-hover // color without resetting the --sl-color-neutral-700 variable () => html` (this.openDialogName = "stop")} ?disabled=${workflow.lastCrawlStopping || this.isCancelingOrStoppingCrawl} > ${msg("Stop Crawl")} (this.openDialogName = "cancel")} > ${msg("Cancel & Discard Crawl")} `, () => html` this.runNow()} > ${msg("Run Crawl")} ` )} ${when( workflow.isCrawlRunning, () => html` (this.openDialogName = "scale")}> ${msg("Edit Crawler Instances")} (this.openDialogName = "exclusions")} > ${msg("Edit Exclusions")} ` )} this.navTo( `/orgs/${this.appState.orgSlug}/workflows/crawl/${workflow.id}?edit` )} > ${msg("Edit Workflow Settings")} CopyButton.copyToClipboard(workflow.tags.join(", "))} ?disabled=${!workflow.tags.length} > ${msg("Copy Tags")} this.duplicateConfig()}> ${msg("Duplicate Workflow")} ${when(!this.lastCrawlId, () => { const shouldDeactivate = workflow.crawlCount && !workflow.inactive; return html` shouldDeactivate ? this.deactivate() : this.delete()} > ${shouldDeactivate ? msg("Deactivate Workflow") : msg("Delete Workflow")} `; })} `; }; private renderDetails() { return html` ${this.renderDetailItem( msg("Status"), () => html` ` )} ${this.renderDetailItem( msg("Total Size"), () => html` ` )} ${this.renderDetailItem(msg("Schedule"), () => this.workflow!.schedule ? html`
${humanizeSchedule(this.workflow!.schedule, { length: "short", })}
` : html`${msg("No Schedule")}` )} ${this.renderDetailItem(msg("Created By"), () => msg( str`${this.workflow!.createdByName} on ${this.dateFormatter.format( new Date(`${this.workflow!.created}Z`) )}` ) )}
`; } private renderDetailItem( label: string | TemplateResult, renderContent: () => any ) { return html` ${when( this.workflow, renderContent, () => html`` )} `; } private renderName() { if (!this.workflow) return ""; if (this.workflow.name) return this.workflow.name; const { seedCount, firstSeed } = this.workflow; if (seedCount === 1) { return firstSeed; } const remainderCount = seedCount - 1; if (remainderCount === 1) { return msg( html`${firstSeed} +${remainderCount} URL` ); } return msg( html`${firstSeed} +${remainderCount} URLs` ); } private renderCrawls() { return html`
${msg("View:")}
{ const value = (e.target as SlSelect).value as CrawlState[]; await this.updateComplete; this.filterBy = { ...this.filterBy, state: value, }; this.fetchCrawls(); }} > ${inactiveCrawlStates.map(this.renderStatusMenuItem)}
${when( this.workflow?.isCrawlRunning, () => html`
${msg( html`Crawl is currently running. Watch Crawl Progress` )}
` )} ${msg("Start Time")} ${when( this.crawls, () => this.crawls!.items.map( (crawl: Crawl) => html` ${when( this.isCrawler, () => html` this.confirmDeleteCrawl(crawl)} > ${msg("Delete Crawl")} ` )}` ), () => html`
` )}
${when( this.crawls && !this.crawls.items.length, () => html`

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

` )}
`; } private renderStatusMenuItem = (state: CrawlState) => { const { icon, label } = CrawlStatus.getContent(state); return html`${icon}${label}`; }; private renderCurrentCrawl = () => { const skeleton = html``; return html` ${this.renderDetailItem(msg("Pages Crawled"), () => this.lastCrawlStats ? msg( str`${this.numberFormatter.format( +(this.lastCrawlStats.done || 0) )} / ${this.numberFormatter.format( +(this.lastCrawlStats.found || 0) )}` ) : html`` )} ${this.renderDetailItem(msg("Run Duration"), () => this.lastCrawlStartTime ? RelativeDuration.humanize( new Date().valueOf() - new Date(`${this.lastCrawlStartTime}Z`).valueOf() ) : skeleton )} ${this.renderDetailItem(msg("Crawl Size"), () => this.workflow ? html`` : skeleton )} ${this.renderDetailItem(msg("Crawler Instances"), () => this.workflow ? this.workflow.scale : skeleton )} `; }; private renderWatchCrawl = () => { if (!this.authState || !this.workflow?.lastCrawlState) return ""; let waitingMsg = null; switch (this.workflow.lastCrawlState) { case "starting": waitingMsg = msg("Crawl starting..."); break; case "waiting_capacity": waitingMsg = msg( "Crawl waiting for available resources before it can continue..." ); break; case "waiting_org_limit": waitingMsg = msg( "Crawl waiting for others to finish, concurrent limit per Organization reached..." ); break; } const isRunning = this.workflow.lastCrawlState === "running"; const isStopping = this.workflow.lastCrawlStopping; const authToken = this.authState.headers.Authorization.split(" ")[1]; return html` ${waitingMsg ? html`

${waitingMsg}

` : isActive(this.workflow.lastCrawlState) ? html` ${isStopping ? html`
${msg("Crawl stopping...")}
` : ""} ` : this.renderInactiveCrawlMessage()} ${when( isRunning, () => html`
${this.renderCrawlErrors()}
${this.renderExclusions()}
(this.openDialogName = undefined)} @sl-show=${this.showDialog} @sl-after-hide=${() => (this.isDialogVisible = false)} > ${this.isDialogVisible ? this.renderEditScale() : ""} ` )} `; }; private renderInactiveWatchCrawl() { return html`

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

${when( this.workflow?.lastCrawlId, () => html` ${msg("Replay Latest Crawl")} ` )} ${when( this.isCrawler, () => html` this.runNow()} > ${msg("Run Crawl")} ` )}
`; } private renderInactiveCrawlMessage() { return html`

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

`; } private renderLogs() { return html`
${when( this.workflow?.isCrawlRunning, () => html`
${msg( html`Viewing error logs for currently running crawl. Watch Crawl Progress` )}
` )} ${when( this.lastCrawlId, () => this.logs?.total ? html` { await this.fetchCrawlLogs({ page: e.detail.page, }); // Scroll to top of list this.scrollIntoView(); }} >` : html`

${this.workflow?.lastCrawlState === "waiting_capacity" ? msg("Error logs currently not available.") : msg("No error logs found yet for latest crawl.")}

`, () => this.renderNoCrawlLogs() )}
`; } private renderNoCrawlLogs() { return html`

${msg("Logs will show here after you run a crawl.")}

this.runNow()} > ${msg("Run Crawl")}
`; } private renderCrawlErrors() { return html`

${msg("Error Logs")} ${this.logs?.total ? this.logs?.total.toLocaleString() : 0}

${when( this.logs?.total && this.logs.total > LOGS_PAGE_SIZE, () => html`

${msg( str`Displaying latest ${LOGS_PAGE_SIZE.toLocaleString()} errors of ${this.logs!.total.toLocaleString()}.` )}

` )}
`; } private renderExclusions() { return html`

${msg("Crawl URLs")}

(this.openDialogName = "exclusions")} > ${msg("Edit Exclusions")}
${when( this.lastCrawlId, () => html` ` )} (this.openDialogName = undefined)} @sl-show=${this.showDialog} @sl-after-hide=${() => (this.isDialogVisible = false)} > ${this.workflow && this.isDialogVisible ? html`` : ""}
${msg("Done Editing")}
`; } private renderEditScale() { if (!this.workflow) return; const scaleOptions = [ { value: 1, label: "1×", }, { value: 2, label: "2×", }, { value: 3, label: "3×", }, ]; return html`
${scaleOptions.map( ({ value, label }) => html` { await this.scale(value); this.openDialogName = undefined; }} ?disabled=${this.isSubmittingUpdate} >${label} ` )}
(this.openDialogName = undefined)} >${msg("Cancel")}
`; } private renderSettings() { return html`
`; } private renderLoading = () => html`
`; private showDialog = async () => { await this.getWorkflowPromise; this.isDialogVisible = true; }; private handleExclusionChange() { this.fetchWorkflow(); } private async scale(value: Crawl["scale"]) { if (!this.lastCrawlId) return; this.isSubmittingUpdate = true; try { const data = await this.apiFetch<{ scaled: boolean }>( `/orgs/${this.orgId}/crawls/${this.lastCrawlId}/scale`, this.authState!, { method: "POST", body: JSON.stringify({ scale: +value }), } ); if (data.scaled) { this.fetchWorkflow(); this.notify({ message: msg("Updated crawl scale."), variant: "success", icon: "check2-circle", }); } else { throw new Error("unhandled API response"); } } catch { this.notify({ message: msg("Sorry, couldn't change crawl scale at this time."), variant: "danger", icon: "exclamation-octagon", }); } this.isSubmittingUpdate = false; } private async getWorkflow(): Promise { const data: Workflow = await this.apiFetch( `/orgs/${this.orgId}/crawlconfigs/${this.workflowId}`, this.authState! ); return data; } private async onCloseExclusions() { const editor = this.querySelector("btrix-exclusion-editor"); if (editor && editor instanceof ExclusionEditor) { await editor.onClose(); } this.openDialogName = undefined; } private async fetchSeeds(): Promise { try { this.getSeedsPromise = this.getSeeds(); this.seeds = await this.getSeedsPromise; } catch { this.notify({ message: msg( "Sorry, couldn't retrieve all crawl settings at this time." ), variant: "danger", icon: "exclamation-octagon", }); } } private async getSeeds() { const data = await this.apiFetch>( `/orgs/${this.orgId}/crawlconfigs/${this.workflowId}/seeds`, this.authState! ); return data; } private async fetchCrawls() { try { this.crawls = await this.getCrawls(); } catch { this.notify({ message: msg("Sorry, couldn't get crawls at this time."), variant: "danger", icon: "exclamation-octagon", }); } } private async getCrawls() { const query = queryString.stringify( { state: this.filterBy.state, cid: this.workflowId, sortBy: "started", }, { arrayFormat: "comma", } ); const data = await this.apiFetch>( `/orgs/${this.orgId}/crawls?${query}`, this.authState! ); return data; } private async fetchCurrentCrawlStats() { if (!this.lastCrawlId) return; try { // TODO see if API can pass stats in GET workflow const { stats } = await this.getCrawl(this.lastCrawlId); this.lastCrawlStats = stats; } catch (e) { // TODO handle error console.debug(e); } } private stopPoll() { window.clearTimeout(this.timerId); } private async getCrawl(crawlId: Crawl["id"]): Promise { const data = await this.apiFetch( `/orgs/${this.orgId}/crawls/${crawlId}/replay.json`, this.authState! ); return data; } /** * Create a new template using existing template data */ private async duplicateConfig() { if (!this.workflow) await this.getWorkflowPromise; if (!this.seeds) await this.getSeedsPromise; await this.updateComplete; if (!this.workflow) return; const workflowParams: WorkflowParams = { ...this.workflow, name: this.workflow.name ? msg(str`${this.workflow.name} Copy`) : "", }; this.navTo( `${this.orgBasePath}/workflows?new&jobType=${workflowParams.jobType}`, { workflow: workflowParams, seeds: this.seeds?.items, } ); this.notify({ message: msg(str`Copied Workflow to new template.`), variant: "success", icon: "check2-circle", }); } private async deactivate(): Promise { if (!this.workflow) return; try { await this.apiFetch( `/orgs/${this.orgId}/crawlconfigs/${this.workflow.id}`, this.authState!, { method: "DELETE", } ); this.workflow = { ...this.workflow, inactive: true, }; this.notify({ message: msg(html`Deactivated ${this.renderName()}.`), variant: "success", icon: "check2-circle", }); } catch { this.notify({ message: msg("Sorry, couldn't deactivate Workflow at this time."), variant: "danger", icon: "exclamation-octagon", }); } } private async delete(): Promise { if (!this.workflow) return; const isDeactivating = this.workflow.crawlCount > 0; try { await this.apiFetch( `/orgs/${this.orgId}/crawlconfigs/${this.workflow.id}`, this.authState!, { method: "DELETE", } ); this.navTo(`${this.orgBasePath}/workflows/crawls`); this.notify({ message: isDeactivating ? msg(html`Deactivated ${this.renderName()}.`) : msg(html`Deleted ${this.renderName()}.`), variant: "success", icon: "check2-circle", }); } catch { this.notify({ message: isDeactivating ? msg("Sorry, couldn't deactivate Workflow at this time.") : msg("Sorry, couldn't delete Workflow at this time."), variant: "danger", icon: "exclamation-octagon", }); } } private async cancel() { if (!this.lastCrawlId) return; this.isCancelingOrStoppingCrawl = true; try { const data = await this.apiFetch<{ success: boolean }>( `/orgs/${this.orgId}/crawls/${this.lastCrawlId}/cancel`, this.authState!, { method: "POST", } ); if (data.success === true) { this.fetchWorkflow(); } else { throw data; } } catch { this.notify({ message: msg("Something went wrong, couldn't cancel crawl."), variant: "danger", icon: "exclamation-octagon", }); } this.isCancelingOrStoppingCrawl = false; } private async stop() { if (!this.lastCrawlId) return; this.isCancelingOrStoppingCrawl = true; try { const data = await this.apiFetch<{ success: boolean }>( `/orgs/${this.orgId}/crawls/${this.lastCrawlId}/stop`, this.authState!, { method: "POST", } ); if (data.success === true) { this.fetchWorkflow(); } else { throw data; } } catch { this.notify({ message: msg("Something went wrong, couldn't stop crawl."), variant: "danger", icon: "exclamation-octagon", }); } this.isCancelingOrStoppingCrawl = false; } private async runNow(): Promise { try { const data = await this.apiFetch<{ started: string | null }>( `/orgs/${this.orgId}/crawlconfigs/${this.workflow!.id}/run`, this.authState!, { method: "POST", } ); this.lastCrawlId = data.started; // remove 'Z' from timestamp to match API response this.lastCrawlStartTime = new Date().toISOString().slice(0, -1); this.logs = undefined; this.fetchWorkflow(); this.goToTab("watch"); this.notify({ message: msg("Starting crawl."), variant: "success", icon: "check2-circle", }); } catch (e: any) { let message = msg("Sorry, couldn't run crawl at this time."); if (e.isApiError && e.statusCode === 403) { if (e.details === "storage_quota_reached") { message = msg("Your org does not have enough storage to run crawls."); } else if (e.details === "exec_minutes_quota_reached") { message = msg( "Your org has used all of its execution minutes for this month." ); } else { message = msg("You do not have permission to run crawls."); } } this.notify({ message: message, variant: "danger", icon: "exclamation-octagon", }); } } private confirmDeleteCrawl = (crawl: Crawl) => { this.crawlToDelete = crawl; this.openDialogName = "delete"; }; private async deleteCrawl(crawl: Crawl) { try { const _data = await this.apiFetch( `/orgs/${crawl.oid}/crawls/delete`, this.authState!, { method: "POST", body: JSON.stringify({ crawl_ids: [crawl.id], }), } ); this.crawlToDelete = null; this.crawls = { ...this.crawls!, items: this.crawls!.items.filter((c) => c.id !== crawl.id), }; this.notify({ message: msg(`Successfully deleted crawl`), variant: "success", icon: "check2-circle", }); this.fetchCrawls(); } catch (e: any) { if (this.crawlToDelete) { this.confirmDeleteCrawl(this.crawlToDelete); } let message = msg( str`Sorry, couldn't delete archived item at this time.` ); if (e.isApiError) { if (e.details == "not_allowed") { message = msg( str`Only org owners can delete other users' archived items.` ); } else if (e.message) { message = e.message; } } this.notify({ message: message, variant: "danger", icon: "exclamation-octagon", }); } } private async fetchCrawlLogs( params: Partial = {} ): Promise { try { this.logs = await this.getCrawlErrors(params); } catch (e: any) { if (e.isApiError && e.statusCode === 503) { // do nothing, keep logs if previously loaded } else { this.notify({ message: msg( "Sorry, couldn't retrieve crawl error logs at this time." ), variant: "danger", icon: "exclamation-octagon", }); } } } private async getCrawlErrors(params: Partial) { const page = params.page || this.logs?.page || 1; const pageSize = params.pageSize || this.logs?.pageSize || LOGS_PAGE_SIZE; const data = await this.apiFetch>( `/orgs/${this.orgId}/crawls/${ this.workflow!.lastCrawlId }/errors?page=${page}&pageSize=${pageSize}`, this.authState! ); return data; } }