Make crawl list interactive (#109)

- Cancel and stop crawl
- Sorts crawls by start time, status and crawl template ID
- Filters crawls by crawl template ID
- Adds shortcut to copy template ID
This commit is contained in:
sua yoo 2022-01-29 10:38:58 -08:00 committed by GitHub
parent 01ad7e656f
commit 2636f33123
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 261 additions and 50 deletions

View File

@ -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",

View File

@ -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;

View File

@ -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<string, any>) {
protected updated(changedProperties: Map<string, any>) {
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`<div
class="w-full flex items-center justify-center my-24 text-4xl"
>
@ -95,24 +107,113 @@ export class CrawlsList extends LiteElement {
}
return html`
<main class="grid grid-cols-5 gap-5">
<header class="col-span-5 flex justify-end">
<div>[Sort by]</div>
</header>
<section class="col-span-5 lg:col-span-1">[Filters]</section>
<section class="col-span-5 lg:col-span-4 border rounded">
<ul>
${this.runningCrawls.map(this.renderCrawlItem)}
${this.finishedCrawls.map(this.renderCrawlItem)}
</ul>
</section>
<main>
<header class="pb-4">${this.renderControls()}</header>
<section>${this.renderCrawlList()}</section>
<footer class="mt-2">
<span class="text-0-400 text-sm">
${this.lastFetched
? msg(html`Last updated:
<sl-format-date
date=${new Date(this.lastFetched).toString()}
month="2-digit"
day="2-digit"
year="2-digit"
hour="numeric"
minute="numeric"
second="numeric"
></sl-format-date>`)
: ""}
</span>
</footer>
</main>
<footer>${this.lastFetched}</footer>
`;
}
private renderCrawlItem = (crawl: Crawl) => {
private renderControls() {
return html`
<div class="grid grid-cols-2 gap-3 items-center">
<div class="col-span-2 md:col-span-1">
<sl-input
class="w-full"
slot="trigger"
placeholder=${msg("Search by Crawl Template ID")}
pill
clearable
@sl-input=${this.onSearchInput}
>
<sl-icon name="search" slot="prefix"></sl-icon>
</sl-input>
</div>
<div class="col-span-2 md:col-span-1 flex items-center justify-end">
<div class="whitespace-nowrap text-sm text-0-600 mr-2">
${msg("Sort by")}
</div>
<sl-dropdown
placement="bottom-end"
distance="4"
@sl-select=${(e: any) => {
const [field, direction] = e.detail.item.value.split("_");
this.orderBy = {
field: field,
direction: direction,
};
}}
>
<sl-button slot="trigger" pill caret
>${(sortableFieldLabels as any)[this.orderBy.field] ||
sortableFieldLabels[
`${this.orderBy.field}_${this.orderBy.direction}`
]}</sl-button
>
<sl-menu>
${Object.entries(sortableFieldLabels).map(
([value, label]) => html`
<sl-menu-item
value=${value}
?checked=${value ===
`${this.orderBy.field}_${this.orderBy.direction}`}
>${label}</sl-menu-item
>
`
)}
</sl-menu>
</sl-dropdown>
<sl-icon-button
name="arrow-down-up"
label=${msg("Reverse sort")}
@click=${() => {
this.orderBy = {
...this.orderBy,
direction: this.orderBy.direction === "asc" ? "desc" : "asc",
};
}}
></sl-icon-button>
</div>
</div>
`;
}
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`
<ul class="border rounded">
${flow(
filterResults,
this.sortCrawls.bind(this),
map(this.renderCrawlItem)
)(this.crawls as any)}
</ul>
`;
}
private renderCrawlItem = ({ item: crawl }: CrawlSearchResult) => {
return html`<li
class="grid grid-cols-12 gap-4 md:gap-6 p-4 leading-none border-t first:border-t-0"
>
@ -123,7 +224,7 @@ export class CrawlsList extends LiteElement {
<div class="text-0-500 text-sm whitespace-nowrap truncate">
<a
class="hover:underline"
href=${`/archives/${crawl.aid}/crawl-templates/${crawl.cid}`}
href=${`/archives/${this.archiveId}/crawl-templates/${crawl.cid}`}
@click=${this.navLink}
>${crawl.cid}</a
>
@ -135,11 +236,11 @@ export class CrawlsList extends LiteElement {
<span
class="inline-block ${crawl.state === "failed"
? "text-red-500"
: crawl.state === "partial_complete"
? "text-emerald-200"
: crawl.state === "running"
: crawl.state === "complete"
? "text-emerald-500"
: isRunning(crawl)
? "text-purple-500"
: "text-emerald-500"}"
: "text-zinc-300"}"
style="font-size: 10px; vertical-align: 2px"
>
&#9679;
@ -147,8 +248,7 @@ export class CrawlsList extends LiteElement {
</div>
<div>
<div
class="whitespace-nowrap truncate mb-1 capitalize${crawl.state ===
"running"
class="whitespace-nowrap mb-1 capitalize${crawl.state === "running"
? " motion-safe:animate-pulse"
: ""}"
>
@ -190,7 +290,7 @@ export class CrawlsList extends LiteElement {
></sl-format-date>
</div>
</div>
<div class="col-span-12 md:col-span-1 text-right">
<div class="col-span-12 md:col-span-1 flex justify-end">
<sl-dropdown>
<sl-icon-button
slot="trigger"
@ -200,8 +300,42 @@ export class CrawlsList extends LiteElement {
></sl-icon-button>
<ul class="text-sm whitespace-nowrap" role="menu">
<li class="p-2 hover:bg-zinc-100 cursor-pointer" role="menuitem">
[item]
${isRunning(crawl)
? html`
<li
class="p-2 hover:bg-zinc-100 cursor-pointer"
role="menuitem"
@click=${(e: any) => {
e.stopPropagation();
this.cancel(crawl.id);
e.target.closest("sl-dropdown").hide();
}}
>
${msg("Cancel immediately")}
</li>
<li
class="p-2 text-danger hover:bg-danger hover:text-white cursor-pointer"
role="menuitem"
@click=${(e: any) => {
e.stopPropagation();
this.stop(crawl.id);
e.target.closest("sl-dropdown").hide();
}}
>
${msg("Stop gracefully")}
</li>
`
: ""}
<li
class="p-2 hover:bg-zinc-100 cursor-pointer"
role="menuitem"
@click=${(e: any) => {
e.stopPropagation();
CopyButton.copyToClipboard(crawl.cid);
e.target.closest("sl-dropdown").hide();
}}
>
${msg("Copy Crawl Template ID")}
</li>
</ul>
</sl-dropdown>
@ -209,8 +343,31 @@ export class CrawlsList extends LiteElement {
</li>`;
};
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<void> {
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);

View File

@ -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"