import type { TemplateResult, HTMLTemplateResult } from "lit"; import { state, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { msg, localized, str } from "@lit/localize"; import { RelativeDuration } from "../../components/relative-duration"; import type { AuthState } from "../../utils/AuthService"; import LiteElement, { html } from "../../utils/LiteElement"; import { CopyButton } from "../../components/copy-button"; import type { Crawl } from "./types"; import { times } from "lodash"; type SectionName = "overview" | "watch" | "replay" | "files" | "logs"; const POLL_INTERVAL_SECONDS = 10; /** * Usage: * ```ts * * ``` */ @localized() export class CrawlDetail extends LiteElement { @property({ type: Object }) authState?: AuthState; // e.g. `/archive/${this.archiveId}/crawls` @property({ type: String }) crawlsBaseUrl!: string; // e.g. `/archive/${this.archiveId}/crawls` @property({ type: String }) crawlsAPIBaseUrl?: string; @property({ type: Boolean }) showArchiveLink = false; @property({ type: String }) crawlId?: string; @state() private crawl?: Crawl; @state() private sectionName: SectionName = "overview"; @state() private isSubmittingUpdate: boolean = false; @state() private openDialogName?: "scale"; @state() private isDialogVisible: boolean = false; // For long polling: private timerId?: number; // TODO localize private numberFormatter = new Intl.NumberFormat(); private get isActive(): boolean | null { if (!this.crawl) return null; return ( this.crawl.state === "running" || this.crawl.state === "starting" || this.crawl.state === "stopping" ); } private get hasFiles(): boolean | null { if (!this.crawl) return null; if (!this.crawl.resources) return false; return this.crawl.resources.length > 0; } async firstUpdated() { if (!this.crawlsBaseUrl) { throw new Error("Crawls base URL not defined"); } this.fetchCrawl(); } updated(changedProperties: Map) { const prevCrawl = changedProperties.get("crawl"); if (prevCrawl && this.crawl) { if (prevCrawl.state === "running" && this.crawl.state !== "running") { this.crawlDone(); } } } connectedCallback(): void { // Set initial active section based on URL #hash value const hash = window.location.hash.slice(1); if (["overview", "watch", "replay", "files", "logs"].includes(hash)) { this.sectionName = hash as SectionName; } super.connectedCallback(); } disconnectedCallback(): void { this.stopPollTimer(); super.disconnectedCallback(); } render() { let sectionContent: string | TemplateResult = ""; switch (this.sectionName) { case "watch": { if (this.crawl) { sectionContent = this.renderWatch(); } else { // TODO loading indicator? return ""; } break; } case "replay": sectionContent = this.renderReplay(); break; case "files": sectionContent = this.renderFiles(); break; case "logs": sectionContent = this.renderLogs(); break; default: sectionContent = this.renderOverview(); break; } return html`
${msg("Back to Crawls")}
${this.renderHeader()}

${msg("Summary")}

${this.renderSummary()}
${this.renderNav()}
${sectionContent}
(this.openDialogName = undefined)} @sl-show=${() => (this.isDialogVisible = true)} @sl-after-hide=${() => (this.isDialogVisible = false)} > ${this.isDialogVisible ? this.renderEditScale() : ""} `; } private renderNav() { const renderNavItem = ({ section, label, }: { section: SectionName; label: any; }) => { const isActive = section === this.sectionName; return html` `; }; return html` `; } private renderHeader() { return html`

${msg( html`Crawl of ${this.crawl ? this.crawl.configName : html``}` )}

${this.isActive ? html` { this.openDialogName = "scale"; this.isDialogVisible = true; }} > ${msg("Scale")} ${msg("Stop")} ${msg("Cancel")} ` : ""} ${this.crawl ? html` ${this.renderMenu()} ` : html``}
`; } private renderMenu() { if (!this.crawl) return; const crawlId = this.crawl.id; const crawlTemplateId = this.crawl.cid; const closeDropdown = (e: any) => { e.target.closest("sl-dropdown").hide(); }; return html` ${this.isActive ? html`` : msg("Actions")} `; } private renderSummary() { return html`
${msg("Status")}
${this.crawl ? html`
${this.crawl.state.replace(/_/g, " ")}
` : html``}
${msg("Pages Crawled")}
${this.crawl?.stats ? html` ${this.numberFormatter.format(+this.crawl.stats.done)} / ${this.numberFormatter.format(+this.crawl.stats.found)} ` : this.crawl ? html` ${msg("Unknown")} ` : html``}
${msg("Run Duration")}
${this.crawl ? html` ${this.crawl.finished ? html`${RelativeDuration.humanize( new Date(`${this.crawl.finished}Z`).valueOf() - new Date(`${this.crawl.started}Z`).valueOf() )}` : html` `} ` : html``}
${msg("Crawl Scale")}
${this.crawl ? html`${this.crawl.scale}` : html``}
`; } private renderWatch() { if (!this.authState || !this.crawl) return ""; const isStarting = this.crawl.state === "starting"; const isRunning = this.crawl.state === "running"; const authToken = this.authState.headers.Authorization.split(" ")[1]; return html`

${msg("Watch Crawl")}

${isRunning && document.fullscreenEnabled ? html` this.enterFullscreen("screencast-crawl")} > ` : ""}
${isStarting ? html`

${msg("Crawl starting...")}

` : isRunning ? html`
` : html`

${msg("Crawl is not running.")} ${this.hasFiles ? html` (this.sectionName = "replay")} >View replay` : ""}

`} `; } private renderReplay() { const bearer = this.authState?.headers?.Authorization?.split(" ", 2)[1]; // for now, just use the first file until multi-wacz support is fully implemented const replaySource = `/api/archives/${this.crawl?.aid}/crawls/${this.crawlId}.json?auth_bearer=${bearer}`; //const replaySource = this.crawl?.resources?.[0]?.path; const canReplay = replaySource && this.hasFiles; return html`

${msg("Replay Crawl")}

${ document.fullscreenEnabled && canReplay ? html` this.enterFullscreen("replay-crawl")} > ` : "" }
${ canReplay ? html`
` : html`

${this.isActive ? msg("No files yet.") : msg("No files to replay.")}

` } `; } private renderOverview() { return html`
${msg("Started")}
${this.crawl ? html` ` : html``}
${msg("Finished")}
${this.crawl ? html` ${this.crawl.finished ? html`` : html`${msg("Pending")}`} ` : html``}
${msg("Reason")}
${this.crawl ? html` ${this.crawl.manual ? msg( html`Manual start by ${this.crawl?.userName || this.crawl?.userid}` ) : msg(html`Scheduled run`)} ` : html``}
${msg("Crawl Template")}
${this.crawl ? html` ${this.crawl.configName} ` : html``}
${msg("Crawl ID")}
${this.crawl ? html` ${this.crawl.id} ` : html``}
${this.showArchiveLink ? html`
${msg("Archive")}
${this.crawl ? html` ${msg("View Archive")} ` : html``}
` : ""}
`; } private renderFiles() { return html`

${msg("Download Files")}

${this.hasFiles ? html` ` : html`

${this.isActive ? msg("No files yet.") : msg("No files to download.")}

`} `; } private renderLogs() { return html`TODO`; } private renderEditScale() { if (!this.crawl) return; const scaleOptions = [ { value: 1, label: msg("Standard"), }, { value: 2, label: msg("Big (2x)"), }, { value: 3, label: msg("Bigger (3x)"), }, ]; return html`
${scaleOptions.map( ({ value, label }) => html` this.scale(value)} ?disabled=${this.isSubmittingUpdate} >${label} ` )}
(this.openDialogName = undefined)} >${msg("Cancel")}
`; } /** * Fetch crawl and update internal state */ private async fetchCrawl(): Promise { try { this.crawl = await this.getCrawl(); if (this.isActive) { // Start timer for next poll this.timerId = window.setTimeout(() => { this.fetchCrawl(); }, 1000 * POLL_INTERVAL_SECONDS); } else { this.stopPollTimer(); } } catch { this.notify({ message: msg("Sorry, couldn't retrieve crawl at this time."), type: "danger", icon: "exclamation-octagon", }); } } async getCrawl(): Promise { // Mock to use in dev: // return import("../../__mocks__/api/archives/[id]/crawls").then( // (module) => module.default.running[0] // // (module) => module.default.finished[0] // ); const data: Crawl = await this.apiFetch( `${this.crawlsAPIBaseUrl || this.crawlsBaseUrl}/${this.crawlId}.json`, this.authState! ); return data; } private async cancel() { if (window.confirm(msg("Are you sure you want to cancel the crawl?"))) { const data = await this.apiFetch( `/archives/${this.crawl!.aid}/crawls/${this.crawlId}/cancel`, this.authState!, { method: "POST", } ); if (data.canceled === true) { this.fetchCrawl(); } else { this.notify({ message: msg("Sorry, couldn't cancel crawl at this time."), type: "danger", icon: "exclamation-octagon", }); } } } private async stop() { if (window.confirm(msg("Are you sure you want to stop the crawl?"))) { const data = await this.apiFetch( `/archives/${this.crawl!.aid}/crawls/${this.crawlId}/stop`, this.authState!, { method: "POST", } ); if (data.stopping_gracefully === true) { this.fetchCrawl(); } else { this.notify({ message: msg("Sorry, couldn't stop crawl at this time."), type: "danger", icon: "exclamation-octagon", }); } } } private async scale(value: Crawl["scale"]) { this.isSubmittingUpdate = true; try { const data = await this.apiFetch( `/archives/${this.crawl!.aid}/crawls/${this.crawlId}/scale`, this.authState!, { method: "POST", body: JSON.stringify({ scale: +value }), } ); if (data.scaled) { this.crawl!.scale = data.scaled; this.notify({ message: msg("Updated crawl scale."), type: "success", icon: "check2-circle", }); } else { throw new Error("unhandled API response"); } this.openDialogName = undefined; this.isDialogVisible = false; } catch { this.notify({ message: msg("Sorry, couldn't change crawl scale at this time."), type: "danger", icon: "exclamation-octagon", }); } this.isSubmittingUpdate = false; } private stopPollTimer() { window.clearTimeout(this.timerId); } /** Callback when crawl is no longer running */ private crawlDone() { if (!this.crawl) return; this.notify({ message: msg( html`Done crawling ${this.crawl.configName}.` ), type: "success", icon: "check2-circle", }); if (this.sectionName === "watch") { // Show replay tab this.sectionName = "replay"; } } /** * Enter fullscreen mode * @param id ID of element to fullscreen */ private async enterFullscreen(id: string) { try { document.getElementById(id)!.requestFullscreen({ // Show browser navigation controls navigationUI: "show", }); } catch (err) { console.error(err); } } } customElements.define("btrix-crawl-detail", CrawlDetail);