browsertrix/frontend/src/pages/org/crawls-list.ts
Tessa Walsh 0fa60ebc45
Rename archives/teams -> orgs in codebase + add db migration (#486)
* Rename archives to orgs and aid to oid on backend

* Rename archive to org and aid to oid in frontend

* Remove translation artifact

* Rename team -> organization

* Add database migrations and run once on startup

* This commit also applies the new by_one_worker decorator to other
asyncio tasks to prevent heavy tasks from being run in each worker.

* Run black, pylint, and husky via pre-commit

* Set db version and use in migrations

* Update and prepare database in single task

* Migrate k8s configmaps
2023-01-18 14:51:04 -08:00

752 lines
22 KiB
TypeScript

import { state, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { msg, localized, str } from "@lit/localize";
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 { RelativeDuration } from "../../components/relative-duration";
import type { AuthState } from "../../utils/AuthService";
import LiteElement, { html } from "../../utils/LiteElement";
import type { Crawl, CrawlConfig, InitialCrawlConfig } from "./types";
import { SlCheckbox } from "@shoelace-style/shoelace";
type CrawlSearchResult = {
item: Crawl;
};
const POLL_INTERVAL_SECONDS = 10;
const MIN_SEARCH_LENGTH = 2;
const sortableFieldLabels = {
started_desc: msg("Newest"),
started_asc: msg("Oldest"),
finished_desc: msg("Recently Updated"),
finished_asc: msg("Oldest Finished"),
state: msg("Status"),
configName: msg("Crawl Name"),
cid: msg("Crawl Config ID"),
fileSize_asc: msg("Smallest Files"),
fileSize_desc: msg("Largest Files"),
};
function isActive(crawl: Crawl) {
return (
crawl.state === "running" ||
crawl.state === "starting" ||
crawl.state === "stopping"
);
}
/**
* Usage:
* ```ts
* <btrix-crawls-list crawlsBaseUrl="/crawls"></btrix-crawls-list>
* ```
*/
@localized()
export class CrawlsList extends LiteElement {
@property({ type: Object })
authState!: AuthState;
@property({ type: String })
userId!: string;
// e.g. `/org/${this.orgId}/crawls`
@property({ type: String })
crawlsBaseUrl!: string;
// e.g. `/org/${this.orgId}/crawls`
@property({ type: String })
crawlsAPIBaseUrl?: string;
/**
* Fetch & refetch data when needed,
* e.g. when component is visible
**/
@property({ type: Boolean })
shouldFetch?: boolean;
@state()
private lastFetched?: number;
@state()
private crawls?: Crawl[];
@state()
private orderBy: {
field: "started";
direction: "asc" | "desc";
} = {
field: "started",
direction: "desc",
};
@state()
private filterByCurrentUser = true;
@state()
private filterBy: string = "";
// For fuzzy search:
private fuse = new Fuse([], {
keys: ["cid", "configName"],
shouldSort: false,
});
private timerId?: number;
// TODO localize
private numberFormatter = new Intl.NumberFormat();
private sortCrawls(crawls: CrawlSearchResult[]): CrawlSearchResult[] {
return orderBy(({ item }) => item[this.orderBy.field])(
this.orderBy.direction
)(crawls) as CrawlSearchResult[];
}
protected willUpdate(changedProperties: Map<string, any>) {
if (
changedProperties.has("shouldFetch") ||
changedProperties.get("crawlsBaseUrl") ||
changedProperties.get("crawlsAPIBaseUrl") ||
changedProperties.has("filterByCurrentUser")
) {
if (this.shouldFetch) {
if (!this.crawlsBaseUrl) {
throw new Error("Crawls base URL not defined");
}
this.fetchCrawls();
} else {
this.stopPollTimer();
}
}
}
disconnectedCallback(): void {
this.stopPollTimer();
super.disconnectedCallback();
}
render() {
if (!this.crawls) {
return html`<div
class="w-full flex items-center justify-center my-24 text-3xl"
>
<sl-spinner></sl-spinner>
</div>`;
}
return html`
<main>
<header class="sticky z-10 mb-3 top-0 py-2 bg-neutral-0">
${this.renderControls()}
</header>
<section>
${this.crawls.length
? this.renderCrawlList()
: html`
<div class="border-t border-b py-5">
<p class="text-center text-neutral-500">
${msg("No crawls yet.")}
</p>
</div>
`}
</section>
<footer class="mt-2">
<span class="text-0-400 text-xs">
${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>
`;
}
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 Config name or ID")}
clearable
?disabled=${!this.crawls?.length}
@sl-input=${this.onSearchInput}
>
<sl-icon name="search" slot="prefix"></sl-icon>
</sl-input>
</div>
<div class="col-span-12 md:col-span-1 flex items-center justify-end">
${this.userId
? html`<label class="mr-3">
<span class="text-neutral-500 mr-1"
>${msg("Show Only Mine")}</span
>
<sl-switch
@sl-change=${(e: CustomEvent) =>
(this.filterByCurrentUser = (
e.target as SlCheckbox
).checked)}
?checked=${this.filterByCurrentUser}
></sl-switch>
</label>`
: ""}
<div class="whitespace-nowrap text-neutral-500 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"
size="small"
pill
caret
?disabled=${!this.crawls?.length}
>${(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="border-t first:border-t-0">
<a
href=${`${this.crawlsBaseUrl}/crawl/${crawl.id}`}
class="grid grid-cols-12 gap-4 p-4 leading-none hover:bg-zinc-50 hover:text-primary transition-colors"
@click=${this.navLink}
>
<div class="col-span-11 md:col-span-5">
<div class="font-medium mb-1">${crawl.configName || crawl.cid}</div>
<div class="text-0-700 text-sm whitespace-nowrap truncate">
<sl-format-date
date=${`${crawl.started}Z` /** Z for UTC */}
month="2-digit"
day="2-digit"
year="2-digit"
hour="numeric"
minute="numeric"
></sl-format-date>
</div>
</div>
<div class="md:order-last col-span-1 flex justify-end">
<sl-dropdown @click=${(e: Event) => e.preventDefault()} hoist>
<sl-icon-button
slot="trigger"
name="three-dots"
label=${msg("More")}
style="font-size: 1rem"
></sl-icon-button>
<ul
class="text-sm text-neutral-800 bg-white whitespace-nowrap"
role="menu"
>
${isActive(crawl)
? html`
<li
class="p-2 hover:bg-zinc-100 cursor-pointer"
role="menuitem"
@click=${(e: any) => {
this.stop(crawl);
e.target.closest("sl-dropdown").hide();
}}
>
<sl-icon
class="inline-block align-middle"
name="slash-circle"
></sl-icon>
<span class="inline-block align-middle">
${msg("Stop gracefully")}
</span>
</li>
<li
class="p-2 text-danger hover:bg-danger hover:text-white cursor-pointer"
role="menuitem"
@click=${(e: any) => {
this.cancel(crawl);
e.target.closest("sl-dropdown").hide();
}}
>
<sl-icon
class="inline-block align-middle"
name="trash3"
></sl-icon>
<span class="inline-block align-middle">
${msg("Cancel immediately")}
</span>
</li>
<hr />
`
: html`
<li
class="p-2 text-purple-500 hover:bg-purple-500 hover:text-white cursor-pointer"
role="menuitem"
@click=${(e: any) => {
this.runNow(crawl);
e.target.closest("sl-dropdown").hide();
}}
>
<sl-icon
class="inline-block align-middle"
name="arrow-clockwise"
></sl-icon>
<span class="inline-block align-middle">
${msg("Re-run crawl")}
</span>
</li>
<hr />
`}
<li
class="p-2 hover:bg-zinc-100 cursor-pointer"
role="menuitem"
@click=${(e: any) => {
CopyButton.copyToClipboard(crawl.id);
e.target.closest("sl-dropdown").hide();
}}
>
${msg("Copy Crawl ID")}
</li>
<li
class="p-2 hover:bg-zinc-100 cursor-pointer"
role="menuitem"
@click=${(e: any) => {
CopyButton.copyToClipboard(crawl.cid);
e.target.closest("sl-dropdown").hide();
}}
>
${msg("Copy Crawl Config ID")}
</li>
<li
class="p-2 hover:bg-zinc-100 cursor-pointer"
role="menuitem"
@click=${(e: any) => {
this.navTo(`${this.crawlsBaseUrl}/crawl/${crawl.id}#config`);
}}
>
${msg("View Crawl Config")}
</li>
</ul>
</sl-dropdown>
</div>
<div class="col-span-12 md:col-span-2 flex items-start">
<div class="mr-2">
<!-- TODO switch case in lit template? needed for tailwindcss purging -->
<span
class="inline-block ${crawl.state === "failed"
? "text-red-500"
: crawl.state === "complete"
? "text-emerald-500"
: isActive(crawl)
? "text-purple-500 motion-safe:animate-pulse"
: "text-zinc-300"}"
style="font-size: 10px; vertical-align: 2px"
>
&#9679;
</span>
</div>
<div>
<div
class="whitespace-nowrap mb-1 capitalize${isActive(crawl)
? " motion-safe:animate-pulse"
: ""}"
>
${crawl.state.replace(/_/g, " ")}
</div>
<div class="text-neutral-500 text-sm whitespace-nowrap truncate">
${crawl.finished
? html`
<sl-relative-time
date=${`${crawl.finished}Z` /** Z for UTC */}
></sl-relative-time>
`
: ""}
${!crawl.finished
? html`
${crawl.state === "canceled" ? msg("Unknown") : ""}
${isActive(crawl) ? this.renderActiveDuration(crawl) : ""}
`
: ""}
</div>
</div>
</div>
<div class="col-span-6 md:col-span-2">
${crawl.finished
? html`
<div class="whitespace-nowrap truncate text-sm">
<span class="font-mono text-0-800 tracking-tighter">
<sl-format-bytes
value=${crawl.fileSize || 0}
></sl-format-bytes>
</span>
<span class="text-neutral-500">
(${crawl.fileCount === 1
? msg(str`${crawl.fileCount} file`)
: msg(str`${crawl.fileCount} files`)})
</span>
</div>
<div
class="text-neutral-500 text-sm whitespace-nowrap truncate"
>
${msg(
str`in ${RelativeDuration.humanize(
new Date(`${crawl.finished}Z`).valueOf() -
new Date(`${crawl.started}Z`).valueOf(),
{ compact: true }
)}`
)}
</div>
`
: crawl.stats
? html`
<div
class="whitespace-nowrap truncate text-sm text-purple-600 font-mono tracking-tighter"
>
${this.numberFormatter.format(+crawl.stats.done)}
<span class="text-0-400">/</span>
${this.numberFormatter.format(+crawl.stats.found)}
</div>
<div
class="text-neutral-500 text-sm whitespace-nowrap truncate"
>
${msg("pages crawled")}
</div>
`
: ""}
</div>
<div class="col-span-6 md:col-span-2">
${crawl.manual
? html`
<div class="whitespace-nowrap truncate mb-1">
<span
class="bg-fuchsia-50 text-fuchsia-700 text-sm rounded px-1 leading-4"
>${msg("Manual Start")}</span
>
</div>
<div
class="ml-1 text-neutral-500 text-sm whitespace-nowrap truncate"
>
${msg(str`by ${crawl.userName || crawl.userid}`)}
</div>
`
: html`
<div class="whitespace-nowrap truncate">
<span
class="bg-teal-50 text-teal-700 text-sm rounded px-1 leading-4"
>${msg("Scheduled Run")}</span
>
</div>
`}
</div>
</a>
</li>`;
};
private renderActiveDuration(crawl: Crawl) {
const endTime = this.lastFetched || Date.now();
const duration = endTime - new Date(`${crawl.started}Z`).valueOf();
let unitCount: number;
let tickSeconds: number | undefined = undefined;
// Show second unit if showing seconds or greater than 1 hr
const showSeconds = duration < 60 * 2 * 1000;
if (showSeconds || duration > 60 * 60 * 1000) {
unitCount = 2;
} else {
unitCount = 1;
}
// Tick if seconds are showing
if (showSeconds) {
tickSeconds = 1;
} else {
tickSeconds = undefined;
}
return html`
<btrix-relative-duration
class="text-purple-500"
value=${`${crawl.started}Z`}
endTime=${this.lastFetched || Date.now()}
unitCount=${unitCount}
tickSeconds=${ifDefined(tickSeconds)}
></btrix-relative-duration>
`;
}
private onSearchInput = debounce(200)((e: any) => {
this.filterBy = e.target.value;
}) as any;
/**
* Fetch crawls and update internal state
*/
private async fetchCrawls(): Promise<void> {
if (!this.shouldFetch) return;
try {
const { crawls } = await this.getCrawls();
this.crawls = crawls;
// Update search/filter collection
this.fuse.setCollection(this.crawls as any);
// Restart timer for next poll
this.stopPollTimer();
this.timerId = window.setTimeout(() => {
this.fetchCrawls();
}, 1000 * POLL_INTERVAL_SECONDS);
} catch (e) {
this.notify({
message: msg("Sorry, couldn't retrieve crawls at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private stopPollTimer() {
window.clearTimeout(this.timerId);
}
private async getCrawls(): Promise<{ crawls: Crawl[] }> {
const params =
this.userId && this.filterByCurrentUser ? `?userid=${this.userId}` : "";
const data = await this.apiFetch(
`${this.crawlsAPIBaseUrl || this.crawlsBaseUrl}${params}`,
this.authState!
);
this.lastFetched = Date.now();
return data;
}
private async cancel(crawl: Crawl) {
if (window.confirm(msg("Are you sure you want to cancel the crawl?"))) {
const data = await this.apiFetch(
`/orgs/${crawl.oid}/crawls/${crawl.id}/cancel`,
this.authState!,
{
method: "POST",
}
);
if (data.success === true) {
this.fetchCrawls();
} else {
this.notify({
message: msg("Something went wrong, couldn't cancel crawl."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
}
private async stop(crawl: Crawl) {
if (window.confirm(msg("Are you sure you want to stop the crawl?"))) {
const data = await this.apiFetch(
`/orgs/${crawl.oid}/crawls/${crawl.id}/stop`,
this.authState!,
{
method: "POST",
}
);
if (data.success === true) {
this.fetchCrawls();
} else {
this.notify({
message: msg("Something went wrong, couldn't stop crawl."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
}
private async runNow(crawl: Crawl) {
// Get crawl config to check if crawl is already running
const crawlTemplate = await this.getCrawlTemplate(crawl);
if (crawlTemplate?.currCrawlId) {
this.notify({
message: msg(
html`Crawl of <strong>${crawl.configName}</strong> is already running.
<br />
<a
class="underline hover:no-underline"
href="/orgs/${crawl.oid}/crawls/crawl/${crawlTemplate.currCrawlId}"
@click=${this.navLink.bind(this)}
>View crawl</a
>`
),
variant: "warning",
icon: "exclamation-triangle",
});
return;
}
try {
const data = await this.apiFetch(
`/orgs/${crawl.oid}/crawlconfigs/${crawl.cid}/run`,
this.authState!,
{
method: "POST",
}
);
if (data.started) {
this.fetchCrawls();
}
this.notify({
message: msg(
html`Started crawl from <strong>${crawl.configName}</strong>.
<br />
<a
class="underline hover:no-underline"
href="/orgs/${crawl.oid}/crawls/crawl/${data.started}#watch"
@click=${this.navLink.bind(this)}
>Watch crawl</a
>`
),
variant: "success",
icon: "check2-circle",
duration: 8000,
});
} catch (e: any) {
if (e.isApiError && e.statusCode === 404) {
this.notify({
message: msg(
html`Sorry, cannot rerun crawl from a deactivated crawl config.
<br />
<button
class="underline hover:no-underline"
@click="${() => this.duplicateConfig(crawl, crawlTemplate)}"
>
Duplicate crawl config
</button>`
),
variant: "danger",
icon: "exclamation-octagon",
duration: 8000,
});
} else {
this.notify({
message: msg("Sorry, couldn't run crawl at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
}
async getCrawlTemplate(crawl: Crawl): Promise<CrawlConfig> {
const data: CrawlConfig = await this.apiFetch(
`/orgs/${crawl.oid}/crawlconfigs/${crawl.cid}`,
this.authState!
);
return data;
}
/**
* Create a new template using existing template data
*/
private async duplicateConfig(crawl: Crawl, template: CrawlConfig) {
const crawlTemplate: InitialCrawlConfig = {
name: msg(str`${template.name} Copy`),
config: template.config,
profileid: template.profileid || null,
jobType: template.jobType,
schedule: template.schedule,
tags: template.tags,
};
this.navTo(`/orgs/${crawl.oid}/crawl-templates/new`, {
crawlTemplate,
});
this.notify({
message: msg(str`Copied crawl configuration to new template.`),
variant: "success",
icon: "check2-circle",
});
}
}
customElements.define("btrix-crawls-list", CrawlsList);