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:
parent
01ad7e656f
commit
2636f33123
@ -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",
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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"
|
||||
>
|
||||
●
|
||||
@ -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);
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user