diff --git a/frontend/package.json b/frontend/package.json index f4979c25..3026f817 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "color": "^4.0.1", "cron-parser": "^4.2.1", "cronstrue": "^1.123.0", + "fuse.js": "^6.5.3", "lit": "^2.0.0", "lodash": "^4.17.21", "path-parser": "^6.1.0", diff --git a/frontend/src/components/copy-button.ts b/frontend/src/components/copy-button.ts index 92813b53..bf09e656 100644 --- a/frontend/src/components/copy-button.ts +++ b/frontend/src/components/copy-button.ts @@ -22,6 +22,10 @@ export class CopyButton extends LitElement { timeoutId?: number; + static copyToClipboard(value: string) { + navigator.clipboard.writeText(value); + } + disconnectedCallback() { window.clearTimeout(this.timeoutId); } @@ -35,7 +39,7 @@ export class CopyButton extends LitElement { } private onClick() { - navigator.clipboard.writeText(this.value!); + CopyButton.copyToClipboard(this.value!); this.isCopied = true; diff --git a/frontend/src/pages/archive/crawls-list.ts b/frontend/src/pages/archive/crawls-list.ts index fd725a12..b27ec9c5 100644 --- a/frontend/src/pages/archive/crawls-list.ts +++ b/frontend/src/pages/archive/crawls-list.ts @@ -1,8 +1,13 @@ import { state, property } from "lit/decorators.js"; import { msg, localized, str } from "@lit/localize"; import humanizeDuration from "pretty-ms"; +import debounce from "lodash/fp/debounce"; +import flow from "lodash/fp/flow"; +import map from "lodash/fp/map"; import orderBy from "lodash/fp/orderBy"; +import Fuse from "fuse.js"; +import { CopyButton } from "../../components/copy-button"; import type { AuthState } from "../../utils/AuthService"; import LiteElement, { html } from "../../utils/LiteElement"; @@ -22,6 +27,22 @@ type Crawl = { completions?: number; }; +type CrawlSearchResult = { + item: Crawl; +}; + +const MIN_SEARCH_LENGTH = 2; +const sortableFieldLabels = { + started_desc: msg("Newest"), + started_asc: msg("Oldest"), + state: msg("Status"), + cid: msg("Crawl Template ID"), +}; + +function isRunning(crawl: Crawl) { + return crawl.state === "running"; +} + /** * Usage: * ```ts @@ -47,10 +68,7 @@ export class CrawlsList extends LiteElement { private lastFetched?: number; @state() - private runningCrawls?: Crawl[]; - - @state() - private finishedCrawls?: Crawl[]; + private crawls?: Crawl[]; @state() private orderBy: { @@ -61,32 +79,26 @@ export class CrawlsList extends LiteElement { direction: "desc", }; - private sortCrawls(crawls: Crawl[]): Crawl[] { - return orderBy(this.orderBy.field)(this.orderBy.direction)( - crawls - ) as Crawl[]; + @state() + private filterBy: string = ""; + + // For fuzzy search: + private fuse = new Fuse([], { keys: ["cid"], shouldSort: false }); + + private sortCrawls(crawls: CrawlSearchResult[]): CrawlSearchResult[] { + return orderBy(({ item }) => item[this.orderBy.field])( + this.orderBy.direction + )(crawls) as CrawlSearchResult[]; } - protected async updated(changedProperties: Map) { + protected updated(changedProperties: Map) { if (this.shouldFetch && changedProperties.has("shouldFetch")) { - try { - const { running, finished } = await this.getCrawls(); - - this.runningCrawls = this.sortCrawls(running); - this.finishedCrawls = this.sortCrawls(finished); - } catch (e) { - this.notify({ - message: msg("Sorry, couldn't retrieve crawls at this time."), - type: "danger", - icon: "exclamation-octagon", - duration: 10000, - }); - } + this.fetchCrawls(); } } render() { - if (!this.runningCrawls || !this.finishedCrawls) { + if (!this.crawls) { return html`
@@ -95,24 +107,113 @@ export class CrawlsList extends LiteElement { } return html` -
-
-
[Sort by]
-
- -
[Filters]
-
-
    - ${this.runningCrawls.map(this.renderCrawlItem)} - ${this.finishedCrawls.map(this.renderCrawlItem)} -
-
+
+
${this.renderControls()}
+
${this.renderCrawlList()}
+
+ + ${this.lastFetched + ? msg(html`Last updated: + `) + : ""} + +
-
${this.lastFetched}
`; } - private renderCrawlItem = (crawl: Crawl) => { + private renderControls() { + return html` +
+
+ + + +
+
+
+ ${msg("Sort by")} +
+ { + const [field, direction] = e.detail.item.value.split("_"); + this.orderBy = { + field: field, + direction: direction, + }; + }} + > + ${(sortableFieldLabels as any)[this.orderBy.field] || + sortableFieldLabels[ + `${this.orderBy.field}_${this.orderBy.direction}` + ]} + + ${Object.entries(sortableFieldLabels).map( + ([value, label]) => html` + ${label} + ` + )} + + + { + this.orderBy = { + ...this.orderBy, + direction: this.orderBy.direction === "asc" ? "desc" : "asc", + }; + }} + > +
+
+ `; + } + + private renderCrawlList() { + // Return search results if valid filter string is available, + // otherwise format crawls list like search results + const filterResults = + this.filterBy.length >= MIN_SEARCH_LENGTH + ? () => this.fuse.search(this.filterBy) + : map((crawl) => ({ item: crawl })); + + return html` +
    + ${flow( + filterResults, + this.sortCrawls.bind(this), + map(this.renderCrawlItem) + )(this.crawls as any)} +
+ `; + } + + private renderCrawlItem = ({ item: crawl }: CrawlSearchResult) => { return html`
  • @@ -123,7 +224,7 @@ export class CrawlsList extends LiteElement {
    ${crawl.cid} @@ -135,11 +236,11 @@ export class CrawlsList extends LiteElement { ● @@ -147,8 +248,7 @@ export class CrawlsList extends LiteElement {
    @@ -190,7 +290,7 @@ export class CrawlsList extends LiteElement { >
    -
    +
    @@ -209,8 +343,31 @@ export class CrawlsList extends LiteElement {
  • `; }; - private async getCrawls(): Promise<{ running: Crawl[]; finished: Crawl[] }> { - // // Mock to use in dev: + private onSearchInput = debounce(200)((e: any) => { + this.filterBy = e.target.value; + }) as any; + + /** + * Fetch crawls and update internal state + */ + private async fetchCrawls(): Promise { + try { + const { crawls } = await this.getCrawls(); + + this.crawls = crawls; + // Update search/filter collection + this.fuse.setCollection(this.crawls as any); + } catch (e) { + this.notify({ + message: msg("Sorry, couldn't retrieve crawls at this time."), + type: "danger", + icon: "exclamation-octagon", + }); + } + } + + private async getCrawls(): Promise<{ crawls: Crawl[] }> { + // Mock to use in dev: // return import("../../__mocks__/api/archives/[id]/crawls").then( // (module) => module.default // ); @@ -224,6 +381,50 @@ export class CrawlsList extends LiteElement { return data; } + + private async cancel(id: string) { + if (window.confirm(msg("Are you sure you want to cancel the crawl?"))) { + const data = await this.apiFetch( + `/archives/${this.archiveId}/crawls/${id}/cancel`, + this.authState!, + { + method: "POST", + } + ); + + if (data.canceled === true) { + this.fetchCrawls(); + } else { + this.notify({ + message: msg("Something went wrong, couldn't cancel crawl."), + type: "danger", + icon: "exclamation-octagon", + }); + } + } + } + + private async stop(id: string) { + if (window.confirm(msg("Are you sure you want to stop the crawl?"))) { + const data = await this.apiFetch( + `/archives/${this.archiveId}/crawls/${id}/stop`, + this.authState!, + { + method: "POST", + } + ); + + if (data.stopped_gracefully === true) { + this.fetchCrawls(); + } else { + this.notify({ + message: msg("Something went wrong, couldn't stop crawl."), + type: "danger", + icon: "exclamation-octagon", + }); + } + } + } } customElements.define("btrix-crawls-list", CrawlsList); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 2a9812d5..a407e0d4 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2646,6 +2646,11 @@ functional-red-black-tree@^1.0.1: resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= +fuse.js@^6.5.3: + version "6.5.3" + resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.5.3.tgz#7446c0acbc4ab0ab36fa602e97499bdb69452b93" + integrity sha512-sA5etGE7yD/pOqivZRBvUBd/NaL2sjAu6QuSaFoe1H2BrJSkH/T/UXAJ8CdXdw7DvY3Hs8CXKYkDWX7RiP5KOg== + get-intrinsic@^1.0.2: version "1.1.1" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6"