Resolves https://github.com/webrecorder/browsertrix/issues/2732 ## Changes Allows users to duplicate workflows with a seed file.
1262 lines
38 KiB
TypeScript
1262 lines
38 KiB
TypeScript
import { localized, msg, str } from "@lit/localize";
|
|
import type { SlDialog, SlSelectEvent } from "@shoelace-style/shoelace";
|
|
import clsx from "clsx";
|
|
import { html, type PropertyValues } from "lit";
|
|
import { customElement, query, state } from "lit/decorators.js";
|
|
import { ifDefined } from "lit/directives/if-defined.js";
|
|
import { when } from "lit/directives/when.js";
|
|
import queryString from "query-string";
|
|
|
|
import {
|
|
ScopeType,
|
|
type ListWorkflow,
|
|
type Seed,
|
|
type Workflow,
|
|
} from "./types";
|
|
|
|
import { BtrixElement } from "@/classes/BtrixElement";
|
|
import type {
|
|
BtrixFilterChipChangeEvent,
|
|
FilterChip,
|
|
} from "@/components/ui/filter-chip";
|
|
import { parsePage, type PageChangeEvent } from "@/components/ui/pagination";
|
|
import { type SelectEvent } from "@/components/ui/search-combobox";
|
|
import { ClipboardController } from "@/controllers/clipboard";
|
|
import { SearchParamsController } from "@/controllers/searchParams";
|
|
import type { SelectJobTypeEvent } from "@/features/crawl-workflows/new-workflow-dialog";
|
|
import { type BtrixChangeWorkflowProfileFilterEvent } from "@/features/crawl-workflows/workflow-profile-filter";
|
|
import type { BtrixChangeWorkflowScheduleFilterEvent } from "@/features/crawl-workflows/workflow-schedule-filter";
|
|
import type { BtrixChangeWorkflowTagFilterEvent } from "@/features/crawl-workflows/workflow-tag-filter";
|
|
import { pageHeader } from "@/layouts/pageHeader";
|
|
import { WorkflowTab } from "@/routes";
|
|
import scopeTypeLabels from "@/strings/crawl-workflows/scopeType";
|
|
import { deleteConfirmation } from "@/strings/ui";
|
|
import type { APIPaginatedList, APIPaginationQuery } from "@/types/api";
|
|
import {
|
|
NewWorkflowOnlyScopeType,
|
|
type StorageSeedFile,
|
|
} from "@/types/workflow";
|
|
import { isApiError } from "@/utils/api";
|
|
import { settingsForDuplicate } from "@/utils/crawl-workflows/settingsForDuplicate";
|
|
import { isArchivingDisabled } from "@/utils/orgs";
|
|
import { tw } from "@/utils/tailwind";
|
|
|
|
type SearchFields = "name" | "firstSeed";
|
|
type SortField = "lastRun" | "name" | "firstSeed" | "created" | "modified";
|
|
const SORT_DIRECTIONS = ["asc", "desc"] as const;
|
|
type SortDirection = (typeof SORT_DIRECTIONS)[number];
|
|
type Sort = {
|
|
field: SortField;
|
|
direction: SortDirection;
|
|
};
|
|
|
|
const FILTER_BY_CURRENT_USER_STORAGE_KEY =
|
|
"btrix.filterByCurrentUser.crawlConfigs";
|
|
const INITIAL_PAGE_SIZE = 10;
|
|
const POLL_INTERVAL_SECONDS = 10;
|
|
const ABORT_REASON_THROTTLE = "throttled";
|
|
|
|
const sortableFields: Record<
|
|
SortField,
|
|
{ label: string; defaultDirection?: SortDirection }
|
|
> = {
|
|
lastRun: {
|
|
label: msg("Latest Crawl"),
|
|
defaultDirection: "desc",
|
|
},
|
|
modified: {
|
|
label: msg("Last Modified"),
|
|
defaultDirection: "desc",
|
|
},
|
|
name: {
|
|
label: msg("Name"),
|
|
defaultDirection: "asc",
|
|
},
|
|
firstSeed: {
|
|
label: msg("Crawl Start URL"),
|
|
defaultDirection: "asc",
|
|
},
|
|
created: {
|
|
label: msg("Date Created"),
|
|
defaultDirection: "desc",
|
|
},
|
|
};
|
|
|
|
const DEFAULT_SORT = {
|
|
field: "lastRun",
|
|
direction: sortableFields["lastRun"].defaultDirection!,
|
|
} as const;
|
|
|
|
const USED_FILTERS = [
|
|
"schedule",
|
|
"isCrawlRunning",
|
|
] as const satisfies (keyof ListWorkflow)[];
|
|
|
|
/**
|
|
* Usage:
|
|
* ```ts
|
|
* <btrix-workflows-list></btrix-workflows-list>
|
|
* ```
|
|
*/
|
|
@customElement("btrix-workflows-list")
|
|
@localized()
|
|
export class WorkflowsList extends BtrixElement {
|
|
static FieldLabels: Record<SearchFields, string> = {
|
|
name: msg("Name"),
|
|
firstSeed: msg("Crawl Start URL"),
|
|
};
|
|
|
|
@state()
|
|
private workflows?: APIPaginatedList<ListWorkflow>;
|
|
|
|
@state()
|
|
private searchOptions: { [x: string]: string }[] = [];
|
|
|
|
@state()
|
|
private isFetching = false;
|
|
|
|
@state()
|
|
private fetchErrorStatusCode?: number;
|
|
|
|
@state()
|
|
private workflowToDelete?: ListWorkflow;
|
|
|
|
@state()
|
|
private orderBy: Sort = DEFAULT_SORT;
|
|
|
|
@state()
|
|
private filterBy: Partial<{ [k in keyof ListWorkflow]: boolean }> = {};
|
|
|
|
@state()
|
|
private filterByCurrentUser = false;
|
|
|
|
@state()
|
|
private filterByTags?: string[];
|
|
|
|
@state()
|
|
private filterByTagsType: "and" | "or" = "or";
|
|
|
|
@state()
|
|
private filterByProfiles?: string[];
|
|
|
|
@query("#deleteDialog")
|
|
private readonly deleteDialog?: SlDialog | null;
|
|
|
|
// For fuzzy search:
|
|
private readonly searchKeys = ["name", "firstSeed"];
|
|
|
|
// Use to cancel requests
|
|
private getWorkflowsController: AbortController | null = null;
|
|
private timerId?: number;
|
|
|
|
private get selectedSearchFilterKey() {
|
|
return Object.keys(WorkflowsList.FieldLabels).find((key) =>
|
|
Boolean((this.filterBy as Record<string, unknown>)[key]),
|
|
);
|
|
}
|
|
|
|
searchParams = new SearchParamsController(this, (params) => {
|
|
this.updateFiltersFromSearchParams(params);
|
|
});
|
|
|
|
private updateFiltersFromSearchParams(
|
|
params = this.searchParams.searchParams,
|
|
) {
|
|
const filterBy = { ...this.filterBy };
|
|
// remove filters no longer present in search params
|
|
for (const key of Object.keys(filterBy)) {
|
|
if (!params.has(key)) {
|
|
filterBy[key as keyof typeof filterBy] = undefined;
|
|
}
|
|
}
|
|
|
|
// remove current user filter if not present in search params
|
|
if (!params.has("mine")) {
|
|
this.filterByCurrentUser = false;
|
|
}
|
|
|
|
if (params.has("tags")) {
|
|
this.filterByTags = params.getAll("tags");
|
|
} else {
|
|
this.filterByTags = undefined;
|
|
}
|
|
|
|
if (params.has("profiles")) {
|
|
this.filterByProfiles = params.getAll("profiles");
|
|
} else {
|
|
this.filterByProfiles = undefined;
|
|
}
|
|
|
|
// add filters present in search params
|
|
for (const [key, value] of params) {
|
|
// Filter by current user
|
|
if (key === "mine") {
|
|
this.filterByCurrentUser = value === "true";
|
|
}
|
|
|
|
if (key === "tagsType") {
|
|
this.filterByTagsType = value === "and" ? "and" : "or";
|
|
}
|
|
|
|
// Sorting field
|
|
if (key === "sortBy") {
|
|
if (value in sortableFields) {
|
|
this.orderBy = {
|
|
field: value as SortField,
|
|
direction:
|
|
// Use default direction for field if available, otherwise use current direction
|
|
sortableFields[value as SortField].defaultDirection ||
|
|
this.orderBy.direction,
|
|
};
|
|
}
|
|
}
|
|
if (key === "sortDir") {
|
|
if (SORT_DIRECTIONS.includes(value as SortDirection)) {
|
|
// Overrides sort direction if specified
|
|
this.orderBy = { ...this.orderBy, direction: value as SortDirection };
|
|
}
|
|
}
|
|
|
|
// Ignored params
|
|
if (
|
|
[
|
|
"page",
|
|
"mine",
|
|
"tags",
|
|
"tagsType",
|
|
"profiles",
|
|
"sortBy",
|
|
"sortDir",
|
|
].includes(key)
|
|
)
|
|
continue;
|
|
|
|
// Convert string bools to filter values
|
|
if (value === "true") {
|
|
filterBy[key as keyof typeof filterBy] = true;
|
|
} else if (value === "false") {
|
|
filterBy[key as keyof typeof filterBy] = false;
|
|
} else {
|
|
filterBy[key as keyof typeof filterBy] = undefined;
|
|
}
|
|
}
|
|
this.filterBy = { ...filterBy };
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
this.updateFiltersFromSearchParams();
|
|
}
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
// Apply filterByCurrentUser from session storage, and transparently update url without pushing to history stack
|
|
// This needs to happen here instead of in the constructor because this only occurs once after the element is connected to the DOM,
|
|
// and so it overrides the filter state set in `updateFiltersFromSearchParams` but only on first render, not on subsequent navigation.
|
|
this.filterByCurrentUser =
|
|
window.sessionStorage.getItem(FILTER_BY_CURRENT_USER_STORAGE_KEY) ===
|
|
"true";
|
|
if (this.filterByCurrentUser) {
|
|
this.searchParams.set("mine", "true", { replace: true });
|
|
}
|
|
}
|
|
|
|
protected async willUpdate(
|
|
changedProperties: PropertyValues<this> & Map<string, unknown>,
|
|
) {
|
|
// Props that reset the page to 1 when changed
|
|
const resetToFirstPageProps = [
|
|
"filterByCurrentUser",
|
|
"filterByTags",
|
|
"filterByTagsType",
|
|
"filterByProfiles",
|
|
"filterByScheduled",
|
|
"filterBy",
|
|
"orderBy",
|
|
];
|
|
|
|
// Props that require a data refetch
|
|
const refetchDataProps = [...resetToFirstPageProps];
|
|
|
|
if (refetchDataProps.some((k) => changedProperties.has(k))) {
|
|
const isInitialRender = resetToFirstPageProps
|
|
.map((k) => changedProperties.get(k))
|
|
.every((v) => v === undefined);
|
|
void this.fetchWorkflows({
|
|
page:
|
|
// If this is the initial render, use the page from the URL or default to 1; otherwise, reset the page to 1
|
|
isInitialRender
|
|
? parsePage(new URLSearchParams(location.search).get("page")) || 1
|
|
: 1,
|
|
});
|
|
}
|
|
if (changedProperties.has("filterByCurrentUser")) {
|
|
window.sessionStorage.setItem(
|
|
FILTER_BY_CURRENT_USER_STORAGE_KEY,
|
|
this.filterByCurrentUser.toString(),
|
|
);
|
|
}
|
|
}
|
|
|
|
protected firstUpdated() {
|
|
void this.fetchConfigSearchValues();
|
|
}
|
|
|
|
protected updated(
|
|
changedProperties: PropertyValues<this> & Map<string, unknown>,
|
|
) {
|
|
if (
|
|
changedProperties.has("filterBy") ||
|
|
changedProperties.has("filterByCurrentUser") ||
|
|
changedProperties.has("filterByTags") ||
|
|
changedProperties.has("filterByTagsType") ||
|
|
changedProperties.has("filterByProfiles") ||
|
|
changedProperties.has("orderBy")
|
|
) {
|
|
this.searchParams.update((params) => {
|
|
// Reset page
|
|
params.delete("page");
|
|
|
|
const newParams = [
|
|
// Known filters
|
|
...USED_FILTERS.map<[string, undefined]>((f) => [f, undefined]),
|
|
|
|
// Existing filters
|
|
...Object.entries(this.filterBy),
|
|
|
|
// Filter by current user
|
|
["mine", this.filterByCurrentUser || undefined],
|
|
|
|
["tags", this.filterByTags],
|
|
|
|
[
|
|
"tagsType",
|
|
this.filterByTagsType !== "or" ? this.filterByTagsType : undefined,
|
|
],
|
|
|
|
["profiles", this.filterByProfiles],
|
|
|
|
// Sorting fields
|
|
[
|
|
"sortBy",
|
|
this.orderBy.field !== DEFAULT_SORT.field
|
|
? this.orderBy.field
|
|
: undefined,
|
|
],
|
|
[
|
|
"sortDir",
|
|
this.orderBy.direction !==
|
|
sortableFields[this.orderBy.field].defaultDirection
|
|
? this.orderBy.direction
|
|
: undefined,
|
|
],
|
|
] satisfies [string, boolean | string | string[] | undefined][];
|
|
|
|
for (const [filter, value] of newParams) {
|
|
if (value !== undefined) {
|
|
if (Array.isArray(value)) {
|
|
// Rather than a more efficient method where we compare the existing & wanted arrays,
|
|
// it's simpler to just delete and re-append values here. If we were working with large
|
|
// arrays, we could change this, but we'll leave it as is for now — if we were working
|
|
// with truly large arrays, we wouldn't be using search params anyways.
|
|
params.delete(filter);
|
|
value.forEach((v) => {
|
|
params.append(filter, v);
|
|
});
|
|
} else {
|
|
params.set(filter, value.toString());
|
|
}
|
|
} else {
|
|
params.delete(filter);
|
|
}
|
|
}
|
|
return params;
|
|
});
|
|
}
|
|
}
|
|
|
|
disconnectedCallback(): void {
|
|
this.cancelInProgressGetWorkflows();
|
|
super.disconnectedCallback();
|
|
}
|
|
|
|
private async fetchWorkflows(params?: APIPaginationQuery) {
|
|
this.fetchErrorStatusCode = undefined;
|
|
|
|
this.cancelInProgressGetWorkflows();
|
|
this.isFetching = true;
|
|
try {
|
|
const workflows = await this.getWorkflows(params);
|
|
this.workflows = workflows;
|
|
} catch (e) {
|
|
if (isApiError(e)) {
|
|
this.fetchErrorStatusCode = e.statusCode;
|
|
} else if ((e as Error).name === "AbortError") {
|
|
console.debug("Fetch archived items aborted to throttle");
|
|
} else {
|
|
this.notify.toast({
|
|
message: msg("Sorry, couldn't retrieve Workflows at this time."),
|
|
variant: "danger",
|
|
icon: "exclamation-octagon",
|
|
id: "workflow-retrieve-error",
|
|
});
|
|
}
|
|
}
|
|
this.isFetching = false;
|
|
|
|
// Restart timer for next poll
|
|
this.timerId = window.setTimeout(() => {
|
|
void this.fetchWorkflows();
|
|
}, 1000 * POLL_INTERVAL_SECONDS);
|
|
}
|
|
|
|
private cancelInProgressGetWorkflows() {
|
|
window.clearTimeout(this.timerId);
|
|
if (this.getWorkflowsController) {
|
|
this.getWorkflowsController.abort(ABORT_REASON_THROTTLE);
|
|
this.getWorkflowsController = null;
|
|
}
|
|
}
|
|
|
|
render() {
|
|
return html`
|
|
<div class="contents">
|
|
${pageHeader({
|
|
title: msg("Crawl Workflows"),
|
|
actions: html`
|
|
${when(
|
|
this.appState.isAdmin,
|
|
() =>
|
|
html`<sl-tooltip content=${msg("Configure crawling defaults")}>
|
|
<sl-icon-button
|
|
href=${`${this.navigate.orgBasePath}/settings/crawling-defaults`}
|
|
class="size-8 text-lg"
|
|
name="gear"
|
|
label=${msg("Edit org crawling settings")}
|
|
@click=${this.navigate.link}
|
|
></sl-icon-button>
|
|
</sl-tooltip>`,
|
|
)}
|
|
${when(
|
|
this.appState.isCrawler,
|
|
() => html`
|
|
<sl-button-group>
|
|
<sl-button
|
|
variant="primary"
|
|
size="small"
|
|
?disabled=${this.org?.readOnly}
|
|
@click=${() =>
|
|
this.navigate.to(
|
|
`${this.navigate.orgBasePath}/workflows/new`,
|
|
{
|
|
scopeType:
|
|
this.appState.userPreferences?.newWorkflowScopeType,
|
|
},
|
|
)}
|
|
>
|
|
<sl-icon slot="prefix" name="plus-lg"></sl-icon>
|
|
${msg("New Workflow")}</sl-button
|
|
>
|
|
<sl-dropdown
|
|
distance="4"
|
|
placement="bottom-end"
|
|
@sl-select=${(e: SlSelectEvent) => {
|
|
const { value } = e.detail.item;
|
|
|
|
if (value) {
|
|
this.dispatchEvent(
|
|
new CustomEvent<SelectJobTypeEvent["detail"]>(
|
|
"select-job-type",
|
|
{
|
|
detail: value as SelectJobTypeEvent["detail"],
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
<sl-button
|
|
slot="trigger"
|
|
size="small"
|
|
variant="primary"
|
|
caret
|
|
?disabled=${this.org?.readOnly}
|
|
>
|
|
<sl-visually-hidden
|
|
>${msg("Scope options")}</sl-visually-hidden
|
|
>
|
|
</sl-button>
|
|
<sl-menu>
|
|
<sl-menu-label> ${msg("Page Crawl")} </sl-menu-label>
|
|
<sl-menu-item value=${ScopeType.Page}
|
|
>${scopeTypeLabels[ScopeType.Page]}</sl-menu-item
|
|
>
|
|
<sl-menu-item value=${NewWorkflowOnlyScopeType.PageList}>
|
|
${scopeTypeLabels[NewWorkflowOnlyScopeType.PageList]}
|
|
</sl-menu-item>
|
|
<sl-menu-item value=${ScopeType.SPA}>
|
|
${scopeTypeLabels[ScopeType.SPA]}
|
|
</sl-menu-item>
|
|
<sl-divider></sl-divider>
|
|
<sl-menu-label>${msg("Site Crawl")}</sl-menu-label>
|
|
<sl-menu-item value=${ScopeType.Prefix}>
|
|
${scopeTypeLabels[ScopeType.Prefix]}
|
|
</sl-menu-item>
|
|
<sl-menu-item value=${ScopeType.Host}>
|
|
${scopeTypeLabels[ScopeType.Host]}
|
|
</sl-menu-item>
|
|
<sl-menu-item value=${ScopeType.Domain}>
|
|
${scopeTypeLabels[ScopeType.Domain]}
|
|
</sl-menu-item>
|
|
<sl-menu-item value=${ScopeType.Custom}>
|
|
${scopeTypeLabels[ScopeType.Custom]}
|
|
</sl-menu-item>
|
|
</sl-menu>
|
|
</sl-dropdown>
|
|
</sl-button-group>
|
|
`,
|
|
)}
|
|
`,
|
|
classNames: tw`border-b-transparent`,
|
|
})}
|
|
<div class="sticky top-2 z-10 mb-3 rounded-lg border bg-neutral-50 p-4">
|
|
${this.renderControls()}
|
|
</div>
|
|
</div>
|
|
|
|
${when(
|
|
this.fetchErrorStatusCode,
|
|
() => html`
|
|
<div>
|
|
<btrix-alert variant="danger">
|
|
${msg(
|
|
`Something unexpected went wrong while retrieving Workflows.`,
|
|
)}
|
|
</btrix-alert>
|
|
</div>
|
|
`,
|
|
() => html`
|
|
<div class="pb-10">
|
|
${this.workflows
|
|
? this.workflows.total
|
|
? this.renderWorkflowList()
|
|
: this.renderEmptyState()
|
|
: this.renderLoading()}
|
|
</div>
|
|
`,
|
|
)}
|
|
${this.renderDialogs()}
|
|
`;
|
|
}
|
|
|
|
private renderDialogs() {
|
|
return html`
|
|
${when(
|
|
this.workflowToDelete,
|
|
(workflow) => html`
|
|
<btrix-dialog id="deleteDialog" .label=${msg("Delete Workflow?")}>
|
|
${deleteConfirmation(this.renderName(workflow))}
|
|
<div slot="footer" class="flex justify-between">
|
|
<sl-button
|
|
size="small"
|
|
.autofocus=${true}
|
|
@click=${() => void this.deleteDialog?.hide()}
|
|
>${msg("Cancel")}</sl-button
|
|
>
|
|
<sl-button
|
|
size="small"
|
|
variant="danger"
|
|
@click=${async () => {
|
|
void this.deleteDialog?.hide();
|
|
|
|
try {
|
|
await this.delete(workflow);
|
|
this.workflowToDelete = undefined;
|
|
} catch {
|
|
void this.deleteDialog?.show();
|
|
}
|
|
}}
|
|
>${msg("Delete Workflow")}</sl-button
|
|
>
|
|
</div>
|
|
</btrix-dialog>
|
|
`,
|
|
)}
|
|
`;
|
|
}
|
|
|
|
private renderControls() {
|
|
return html`
|
|
<div class="flex flex-wrap items-center gap-2 md:gap-4">
|
|
<div class="grow basis-1/2">${this.renderSearch()}</div>
|
|
|
|
<div class="flex items-center">
|
|
<label
|
|
class="mr-2 whitespace-nowrap text-sm text-neutral-500"
|
|
for="sort-select"
|
|
>
|
|
${msg("Sort by:")}
|
|
</label>
|
|
<sl-select
|
|
id="sort-select"
|
|
class="flex-1 md:min-w-[9.2rem]"
|
|
size="small"
|
|
pill
|
|
value=${this.orderBy.field}
|
|
@sl-change=${(e: Event) => {
|
|
const field = (e.target as HTMLSelectElement).value as SortField;
|
|
this.orderBy = {
|
|
field: field,
|
|
direction:
|
|
sortableFields[field].defaultDirection ||
|
|
this.orderBy.direction,
|
|
};
|
|
}}
|
|
>
|
|
${Object.entries(sortableFields).map(
|
|
([value, { label }]) => html`
|
|
<sl-option value=${value}>${label}</sl-option>
|
|
`,
|
|
)}
|
|
</sl-select>
|
|
<sl-tooltip
|
|
content=${this.orderBy.direction === "asc"
|
|
? msg("Sort in descending order")
|
|
: msg("Sort in ascending order")}
|
|
>
|
|
<sl-icon-button
|
|
name=${this.orderBy.direction === "asc"
|
|
? "sort-up-alt"
|
|
: "sort-down"}
|
|
class="text-base"
|
|
label=${this.orderBy.direction === "asc"
|
|
? msg("Sort Descending")
|
|
: msg("Sort Ascending")}
|
|
@click=${() => {
|
|
this.orderBy = {
|
|
...this.orderBy,
|
|
direction: this.orderBy.direction === "asc" ? "desc" : "asc",
|
|
};
|
|
}}
|
|
></sl-icon-button>
|
|
</sl-tooltip>
|
|
</div>
|
|
|
|
${this.renderFilters()}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderFilters() {
|
|
return html`<div class="flex flex-wrap items-center gap-2">
|
|
<span class="whitespace-nowrap text-sm text-neutral-500">
|
|
${msg("Filter by:")}
|
|
</span>
|
|
|
|
<btrix-workflow-schedule-filter
|
|
.schedule=${this.filterBy.schedule}
|
|
@btrix-change=${(e: BtrixChangeWorkflowScheduleFilterEvent) => {
|
|
this.filterBy = {
|
|
...this.filterBy,
|
|
schedule: e.detail.value,
|
|
};
|
|
}}
|
|
></btrix-workflow-schedule-filter>
|
|
|
|
<btrix-workflow-tag-filter
|
|
.tags=${this.filterByTags}
|
|
@btrix-change=${(e: BtrixChangeWorkflowTagFilterEvent) => {
|
|
this.filterByTags = e.detail.value?.tags;
|
|
this.filterByTagsType = e.detail.value?.type || "or";
|
|
}}
|
|
></btrix-workflow-tag-filter>
|
|
|
|
<btrix-workflow-profile-filter
|
|
.profiles=${this.filterByProfiles}
|
|
@btrix-change=${(e: BtrixChangeWorkflowProfileFilterEvent) => {
|
|
this.filterByProfiles = e.detail.value;
|
|
}}
|
|
></btrix-workflow-profile-filter>
|
|
|
|
<btrix-filter-chip
|
|
?checked=${this.filterBy.isCrawlRunning === true}
|
|
@btrix-change=${(e: BtrixFilterChipChangeEvent) => {
|
|
const { checked } = e.target as FilterChip;
|
|
|
|
this.filterBy = {
|
|
...this.filterBy,
|
|
isCrawlRunning: checked ? true : undefined,
|
|
};
|
|
}}
|
|
>
|
|
${msg("Running")}
|
|
</btrix-filter-chip>
|
|
|
|
<btrix-filter-chip
|
|
?checked=${this.filterByCurrentUser}
|
|
@btrix-change=${(e: BtrixFilterChipChangeEvent) => {
|
|
const { checked } = e.target as FilterChip;
|
|
|
|
this.filterByCurrentUser = Boolean(checked);
|
|
}}
|
|
>
|
|
${msg("Mine")}
|
|
</btrix-filter-chip>
|
|
|
|
${when(
|
|
[
|
|
this.filterBy.schedule,
|
|
this.filterBy.isCrawlRunning,
|
|
this.filterByCurrentUser || undefined,
|
|
this.filterByTags,
|
|
].filter((v) => v !== undefined).length > 1,
|
|
() => html`
|
|
<sl-button
|
|
class="[--sl-color-primary-600:var(--sl-color-neutral-500)] part-[label]:font-medium"
|
|
size="small"
|
|
variant="text"
|
|
@click=${() => {
|
|
this.filterBy = {
|
|
...this.filterBy,
|
|
schedule: undefined,
|
|
isCrawlRunning: undefined,
|
|
};
|
|
this.filterByCurrentUser = false;
|
|
this.filterByTags = undefined;
|
|
}}
|
|
>
|
|
<sl-icon slot="prefix" name="x-lg"></sl-icon>
|
|
${msg("Clear All")}
|
|
</sl-button>
|
|
`,
|
|
)}
|
|
</div>`;
|
|
}
|
|
|
|
private renderSearch() {
|
|
return html`
|
|
<btrix-search-combobox
|
|
.searchKeys=${this.searchKeys}
|
|
.searchOptions=${this.searchOptions}
|
|
.keyLabels=${WorkflowsList.FieldLabels}
|
|
selectedKey=${ifDefined(this.selectedSearchFilterKey)}
|
|
placeholder=${msg("Search all Workflows by name or Crawl Start URL")}
|
|
@btrix-select=${(e: SelectEvent<typeof this.searchKeys>) => {
|
|
const { key, value } = e.detail;
|
|
if (key == null) return;
|
|
this.filterBy = {
|
|
[key]: value,
|
|
};
|
|
}}
|
|
@btrix-clear=${() => {
|
|
const {
|
|
name: _name,
|
|
firstSeed: _firstSeed,
|
|
...otherFilters
|
|
} = this.filterBy;
|
|
this.filterBy = otherFilters;
|
|
}}
|
|
>
|
|
</btrix-search-combobox>
|
|
`;
|
|
}
|
|
|
|
private renderWorkflowList() {
|
|
if (!this.workflows) return;
|
|
const { page, total, pageSize } = this.workflows;
|
|
return html`
|
|
<btrix-workflow-list>
|
|
${this.workflows.items.map(this.renderWorkflowItem)}
|
|
</btrix-workflow-list>
|
|
<footer
|
|
class=${clsx(
|
|
tw`mt-6 flex justify-center`,
|
|
total <= pageSize && tw`hidden`,
|
|
)}
|
|
>
|
|
<btrix-pagination
|
|
page=${page}
|
|
totalCount=${total}
|
|
size=${pageSize}
|
|
@page-change=${async (e: PageChangeEvent) => {
|
|
await this.fetchWorkflows({
|
|
page: e.detail.page,
|
|
});
|
|
|
|
// Scroll to top of list
|
|
// TODO once deep-linking is implemented, scroll to top of pushstate
|
|
this.scrollIntoView({ behavior: "smooth" });
|
|
}}
|
|
></btrix-pagination>
|
|
</footer>
|
|
`;
|
|
}
|
|
|
|
private readonly renderWorkflowItem = (workflow: ListWorkflow) => html`
|
|
<btrix-workflow-list-item .workflow=${workflow}>
|
|
<sl-menu slot="menu">${this.renderMenuItems(workflow)}</sl-menu>
|
|
</btrix-workflow-list-item>
|
|
`;
|
|
|
|
private renderMenuItems(workflow: ListWorkflow) {
|
|
return html`
|
|
${when(
|
|
workflow.isCrawlRunning && this.appState.isCrawler,
|
|
// HACK shoelace doesn't current have a way to override non-hover
|
|
// color without resetting the --sl-color-neutral-700 variable
|
|
() => html`
|
|
<sl-menu-item
|
|
@click=${() => void this.stop(workflow.lastCrawlId)}
|
|
?disabled=${workflow.lastCrawlStopping}
|
|
>
|
|
<sl-icon name="dash-square" slot="prefix"></sl-icon>
|
|
${msg("Stop Crawl")}
|
|
</sl-menu-item>
|
|
<sl-menu-item
|
|
style="--sl-color-neutral-700: var(--danger)"
|
|
@click=${() => void this.cancel(workflow.lastCrawlId)}
|
|
>
|
|
<sl-icon name="x-octagon" slot="prefix"></sl-icon>
|
|
${msg(html`Cancel & Discard Crawl`)}
|
|
</sl-menu-item>
|
|
`,
|
|
)}
|
|
${when(
|
|
this.appState.isCrawler && !workflow.isCrawlRunning,
|
|
() => html`
|
|
<sl-menu-item
|
|
style="--sl-color-neutral-700: var(--success)"
|
|
?disabled=${isArchivingDisabled(this.org, true)}
|
|
@click=${() => void this.runNow(workflow)}
|
|
>
|
|
<sl-icon name="play" slot="prefix"></sl-icon>
|
|
${msg("Run Crawl")}
|
|
</sl-menu-item>
|
|
`,
|
|
)}
|
|
${when(
|
|
this.appState.isCrawler &&
|
|
workflow.isCrawlRunning &&
|
|
!workflow.lastCrawlStopping,
|
|
// HACK shoelace doesn't current have a way to override non-hover
|
|
// color without resetting the --sl-color-neutral-700 variable
|
|
() => html`
|
|
<sl-divider></sl-divider>
|
|
<sl-menu-item
|
|
@click=${() =>
|
|
this.navigate.to(
|
|
`${this.navigate.orgBasePath}/workflows/${workflow.id}/${WorkflowTab.LatestCrawl}`,
|
|
{
|
|
dialog: "scale",
|
|
},
|
|
)}
|
|
>
|
|
<sl-icon name="plus-slash-minus" slot="prefix"></sl-icon>
|
|
${msg("Edit Browser Windows")}
|
|
</sl-menu-item>
|
|
<sl-menu-item
|
|
?disabled=${workflow.lastCrawlState !== "running"}
|
|
@click=${() =>
|
|
this.navigate.to(
|
|
`${this.navigate.orgBasePath}/workflows/${workflow.id}/${WorkflowTab.LatestCrawl}`,
|
|
{
|
|
dialog: "exclusions",
|
|
},
|
|
)}
|
|
>
|
|
<sl-icon name="table" slot="prefix"></sl-icon>
|
|
${msg("Edit Exclusions")}
|
|
</sl-menu-item>
|
|
<sl-divider></sl-divider>
|
|
`,
|
|
)}
|
|
${when(
|
|
this.appState.isCrawler,
|
|
() =>
|
|
html`<sl-menu-item
|
|
@click=${() =>
|
|
this.navigate.to(
|
|
`${this.navigate.orgBasePath}/workflows/${workflow.id}?edit`,
|
|
)}
|
|
>
|
|
<sl-icon name="gear" slot="prefix"></sl-icon>
|
|
${msg("Edit Workflow Settings")}
|
|
</sl-menu-item>`,
|
|
)}
|
|
<sl-menu-item
|
|
@click=${() =>
|
|
ClipboardController.copyToClipboard(workflow.tags.join(", "))}
|
|
?disabled=${!workflow.tags.length}
|
|
>
|
|
<sl-icon name="tags" slot="prefix"></sl-icon>
|
|
${msg("Copy Tags")}
|
|
</sl-menu-item>
|
|
${when(
|
|
this.appState.isCrawler,
|
|
() => html`
|
|
<sl-menu-item
|
|
?disabled=${isArchivingDisabled(this.org, true)}
|
|
@click=${() => void this.duplicateConfig(workflow)}
|
|
>
|
|
<sl-icon name="files" slot="prefix"></sl-icon>
|
|
${msg("Duplicate Workflow")}
|
|
</sl-menu-item>
|
|
<sl-divider></sl-divider>
|
|
<sl-menu-item
|
|
@click=${() => ClipboardController.copyToClipboard(workflow.id)}
|
|
>
|
|
<sl-icon name="copy" slot="prefix"></sl-icon>
|
|
${msg("Copy Workflow ID")}
|
|
</sl-menu-item>
|
|
${when(
|
|
!workflow.crawlCount,
|
|
() => html`
|
|
<sl-divider></sl-divider>
|
|
<sl-menu-item
|
|
style="--sl-color-neutral-700: var(--danger)"
|
|
@click=${async () => {
|
|
this.workflowToDelete = workflow;
|
|
await this.updateComplete;
|
|
void this.deleteDialog?.show();
|
|
}}
|
|
>
|
|
<sl-icon name="trash3" slot="prefix"></sl-icon>
|
|
${msg("Delete Workflow")}
|
|
</sl-menu-item>
|
|
`,
|
|
)}
|
|
`,
|
|
)}
|
|
`;
|
|
}
|
|
|
|
private renderName(crawlConfig: ListWorkflow) {
|
|
if (crawlConfig.name) return crawlConfig.name;
|
|
const { firstSeed, seedCount } = crawlConfig;
|
|
if (seedCount === 1) {
|
|
return firstSeed;
|
|
}
|
|
const remainderCount = seedCount - 1;
|
|
if (remainderCount === 1) {
|
|
return msg(
|
|
html`${firstSeed}
|
|
<span class="text-neutral-500">+${remainderCount} URL</span>`,
|
|
);
|
|
}
|
|
return msg(
|
|
html`${firstSeed}
|
|
<span class="text-neutral-500">+${remainderCount} URLs</span>`,
|
|
);
|
|
}
|
|
|
|
private renderEmptyState() {
|
|
if (
|
|
Object.keys(this.filterBy).length ||
|
|
this.filterByCurrentUser ||
|
|
this.filterByTags
|
|
) {
|
|
return html`
|
|
<div class="rounded-lg border bg-neutral-50 p-4">
|
|
<p class="text-center">
|
|
<span class="text-neutral-400"
|
|
>${msg("No matching Workflows found.")}</span
|
|
>
|
|
<button
|
|
class="font-medium text-neutral-500 underline hover:no-underline"
|
|
@click=${() => {
|
|
this.filterBy = {};
|
|
this.filterByCurrentUser = false;
|
|
this.filterByTags = undefined;
|
|
}}
|
|
>
|
|
${msg("Clear search and filters")}
|
|
</button>
|
|
</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (this.workflows?.page && this.workflows.page > 1) {
|
|
return html`
|
|
<div class="border-b border-t py-5">
|
|
<p class="text-center text-neutral-500">
|
|
${msg("Could not find page.")}
|
|
</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (this.isFetching) {
|
|
return this.renderLoading();
|
|
}
|
|
|
|
return html`
|
|
<div class="border-b border-t py-5">
|
|
<p class="text-center text-neutral-500">${msg("No Workflows yet.")}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderLoading() {
|
|
return html`<div
|
|
class="my-24 flex w-full items-center justify-center text-3xl"
|
|
>
|
|
<sl-spinner></sl-spinner>
|
|
</div>`;
|
|
}
|
|
|
|
/**
|
|
* Fetch Workflows and update state
|
|
**/
|
|
private async getWorkflows(
|
|
queryParams?: APIPaginationQuery & Record<string, unknown>,
|
|
) {
|
|
const query = queryString.stringify(
|
|
{
|
|
...this.filterBy,
|
|
page:
|
|
queryParams?.page ||
|
|
this.workflows?.page ||
|
|
parsePage(new URLSearchParams(location.search).get("page")),
|
|
pageSize:
|
|
queryParams?.pageSize ||
|
|
this.workflows?.pageSize ||
|
|
INITIAL_PAGE_SIZE,
|
|
userid: this.filterByCurrentUser ? this.userInfo?.id : undefined,
|
|
tag: this.filterByTags || undefined,
|
|
tagMatch: this.filterByTagsType,
|
|
profileIds: this.filterByProfiles || undefined,
|
|
sortBy: this.orderBy.field,
|
|
sortDirection: this.orderBy.direction === "desc" ? -1 : 1,
|
|
},
|
|
{
|
|
arrayFormat: "none", // For tags
|
|
},
|
|
);
|
|
|
|
this.getWorkflowsController = new AbortController();
|
|
const data = await this.api.fetch<APIPaginatedList<Workflow>>(
|
|
`/orgs/${this.orgId}/crawlconfigs?${query}`,
|
|
{
|
|
signal: this.getWorkflowsController.signal,
|
|
},
|
|
);
|
|
this.getWorkflowsController = null;
|
|
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Create a new template using existing template data
|
|
*/
|
|
private async duplicateConfig(workflow: ListWorkflow) {
|
|
const fullWorkflow = await this.getWorkflow(workflow);
|
|
let seeds;
|
|
let seedFile;
|
|
|
|
if (fullWorkflow.config.seedFileId) {
|
|
seedFile = await this.getSeedFile(fullWorkflow.config.seedFileId);
|
|
} else {
|
|
seeds = await this.getSeeds(workflow);
|
|
}
|
|
|
|
const settings = settingsForDuplicate({
|
|
workflow: fullWorkflow,
|
|
seeds,
|
|
seedFile,
|
|
});
|
|
|
|
this.navigate.to(`${this.navigate.orgBasePath}/workflows/new`, settings);
|
|
|
|
if (seeds && seeds.total > seeds.items.length) {
|
|
const urlCount = this.localize.number(seeds.items.length);
|
|
|
|
// This is likely an edge case for old workflows with >1,000 seeds
|
|
// or URL list workflows created via API.
|
|
this.notify.toast({
|
|
title: msg(str`Partially copied workflow settings`),
|
|
message: msg(str`The first ${urlCount} URLs were copied.`),
|
|
variant: "warning",
|
|
id: "workflow-copied-status",
|
|
});
|
|
} else {
|
|
this.notify.toast({
|
|
message: msg("Copied settings to new workflow."),
|
|
variant: "success",
|
|
icon: "check2-circle",
|
|
id: "workflow-copied-status",
|
|
});
|
|
}
|
|
}
|
|
|
|
private async delete(workflow: ListWorkflow): Promise<void> {
|
|
try {
|
|
await this.api.fetch(`/orgs/${this.orgId}/crawlconfigs/${workflow.id}`, {
|
|
method: "DELETE",
|
|
});
|
|
|
|
void this.fetchWorkflows();
|
|
this.notify.toast({
|
|
message: msg(
|
|
html`Deleted <strong>${this.renderName(workflow)}</strong> Workflow.`,
|
|
),
|
|
variant: "success",
|
|
icon: "check2-circle",
|
|
id: "workflow-delete-status",
|
|
});
|
|
} catch {
|
|
this.notify.toast({
|
|
message: msg("Sorry, couldn't delete Workflow at this time."),
|
|
variant: "danger",
|
|
icon: "exclamation-octagon",
|
|
id: "workflow-delete-status",
|
|
});
|
|
}
|
|
}
|
|
|
|
private async cancel(crawlId: ListWorkflow["lastCrawlId"]) {
|
|
if (!crawlId) return;
|
|
if (window.confirm(msg("Are you sure you want to cancel the crawl?"))) {
|
|
const data = await this.api.fetch<{ success: boolean }>(
|
|
`/orgs/${this.orgId}/crawls/${crawlId}/cancel`,
|
|
{
|
|
method: "POST",
|
|
},
|
|
);
|
|
if (data.success) {
|
|
void this.fetchWorkflows();
|
|
} else {
|
|
this.notify.toast({
|
|
message: msg("Something went wrong, couldn't cancel crawl."),
|
|
variant: "danger",
|
|
icon: "exclamation-octagon",
|
|
id: "crawl-stop-error",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
private async stop(crawlId: ListWorkflow["lastCrawlId"]) {
|
|
if (!crawlId) return;
|
|
if (window.confirm(msg("Are you sure you want to stop the crawl?"))) {
|
|
const data = await this.api.fetch<{ success: boolean }>(
|
|
`/orgs/${this.orgId}/crawls/${crawlId}/stop`,
|
|
{
|
|
method: "POST",
|
|
},
|
|
);
|
|
if (data.success) {
|
|
void this.fetchWorkflows();
|
|
} else {
|
|
this.notify.toast({
|
|
message: msg("Something went wrong, couldn't stop crawl."),
|
|
variant: "danger",
|
|
icon: "exclamation-octagon",
|
|
id: "crawl-stop-error",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
private async runNow(workflow: ListWorkflow): Promise<void> {
|
|
try {
|
|
await this.api.fetch(
|
|
`/orgs/${this.orgId}/crawlconfigs/${workflow.id}/run`,
|
|
{
|
|
method: "POST",
|
|
},
|
|
);
|
|
|
|
this.notify.toast({
|
|
message: msg(
|
|
html`Started crawl from <strong>${this.renderName(workflow)}</strong>.
|
|
<br />
|
|
<a
|
|
class="underline hover:no-underline"
|
|
href="${this.navigate
|
|
.orgBasePath}/workflows/${workflow.id}/${WorkflowTab.LatestCrawl}"
|
|
@click=${this.navigate.link.bind(this)}
|
|
>Watch crawl</a
|
|
>`,
|
|
),
|
|
variant: "success",
|
|
icon: "check2-circle",
|
|
duration: 8000,
|
|
});
|
|
|
|
await this.fetchWorkflows();
|
|
// Scroll to top of list
|
|
this.scrollIntoView({ behavior: "smooth" });
|
|
} catch (e) {
|
|
let message = msg("Sorry, couldn't run crawl at this time.");
|
|
if (isApiError(e) && e.statusCode === 403) {
|
|
if (e.details === "storage_quota_reached") {
|
|
message = msg("Your org does not have enough storage to run crawls.");
|
|
} else if (e.details === "exec_minutes_quota_reached") {
|
|
message = msg(
|
|
"Your org has used all of its execution minutes for this month.",
|
|
);
|
|
} else {
|
|
message = msg("You do not have permission to run crawls.");
|
|
}
|
|
} else if (isApiError(e) && e.details == "proxy_not_found") {
|
|
message = msg(
|
|
"Your org doesn't have permission to use the proxy configured for this crawl.",
|
|
);
|
|
}
|
|
this.notify.toast({
|
|
message: message,
|
|
variant: "danger",
|
|
icon: "exclamation-octagon",
|
|
id: "crawl-start-error",
|
|
});
|
|
}
|
|
}
|
|
|
|
private async fetchConfigSearchValues() {
|
|
try {
|
|
const data: {
|
|
crawlIds: string[];
|
|
names: string[];
|
|
descriptions: string[];
|
|
firstSeeds: string[];
|
|
} = await this.api.fetch(
|
|
`/orgs/${this.orgId}/crawlconfigs/search-values`,
|
|
);
|
|
|
|
// Update search/filter collection
|
|
const toSearchItem = (key: SearchFields) => (value: string) => ({
|
|
[key]: value,
|
|
});
|
|
this.searchOptions = [
|
|
...data.names.map(toSearchItem("name")),
|
|
...data.firstSeeds.map(toSearchItem("firstSeed")),
|
|
];
|
|
} catch (e) {
|
|
console.debug(e);
|
|
}
|
|
}
|
|
|
|
private async getWorkflow(workflow: ListWorkflow): Promise<Workflow> {
|
|
const data: Workflow = await this.api.fetch(
|
|
`/orgs/${this.orgId}/crawlconfigs/${workflow.id}`,
|
|
);
|
|
return data;
|
|
}
|
|
|
|
private async getSeeds(workflow: ListWorkflow) {
|
|
// NOTE Returns first 1000 seeds (backend pagination max)
|
|
const data = await this.api.fetch<APIPaginatedList<Seed>>(
|
|
`/orgs/${this.orgId}/crawlconfigs/${workflow.id}/seeds`,
|
|
);
|
|
return data;
|
|
}
|
|
|
|
private async getSeedFile(seedFileId: string) {
|
|
const data = await this.api.fetch<StorageSeedFile>(
|
|
`/orgs/${this.orgId}/files/${seedFileId}`,
|
|
);
|
|
return data;
|
|
}
|
|
}
|