- Defaults to user's browser language preference when displaying dates, bytes, and numbers (with caveats.) This fixes an issue where numbers were always formatted in English. - Shows user's browser language preferences in language dropdown - Fixes timezone not displayed in archived item detail start and finish times - Standardizes number formatting - Sets timezone that unit tests run in --------- Co-authored-by: SuaYoo <SuaYoo@users.noreply.github.com> Co-authored-by: Emma Segal-Grossman <hi@emma.cafe> Co-authored-by: emma-sg <emma-sg@users.noreply.github.com>
911 lines
28 KiB
TypeScript
911 lines
28 KiB
TypeScript
import { localized, msg, str } from "@lit/localize";
|
|
import type { SlCheckbox, SlSelectEvent } from "@shoelace-style/shoelace";
|
|
import { html, type PropertyValues } from "lit";
|
|
import { customElement, 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,
|
|
type WorkflowParams,
|
|
} from "./types";
|
|
|
|
import { BtrixElement } from "@/classes/BtrixElement";
|
|
import { CopyButton } from "@/components/ui/copy-button";
|
|
import type { PageChangeEvent } from "@/components/ui/pagination";
|
|
import { type SelectEvent } from "@/components/ui/search-combobox";
|
|
import type { SelectJobTypeEvent } from "@/features/crawl-workflows/new-workflow-dialog";
|
|
import { pageHeader } from "@/layouts/pageHeader";
|
|
import scopeTypeLabels from "@/strings/crawl-workflows/scopeType";
|
|
import type { APIPaginatedList, APIPaginationQuery } from "@/types/api";
|
|
import { NewWorkflowOnlyScopeType } from "@/types/workflow";
|
|
import { isApiError } from "@/utils/api";
|
|
import { isArchivingDisabled } from "@/utils/orgs";
|
|
import { tw } from "@/utils/tailwind";
|
|
|
|
type SearchFields = "name" | "firstSeed";
|
|
type SortField = "lastRun" | "name" | "firstSeed" | "created" | "modified";
|
|
type SortDirection = "asc" | "desc";
|
|
|
|
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";
|
|
// NOTE Backend pagination max is 1000
|
|
const SEEDS_MAX = 1000;
|
|
|
|
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",
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Usage:
|
|
* ```ts
|
|
* <btrix-workflows-list></btrix-workflows-list>
|
|
* ```
|
|
*/
|
|
@localized()
|
|
@customElement("btrix-workflows-list")
|
|
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 orderBy: {
|
|
field: SortField;
|
|
direction: SortDirection;
|
|
} = {
|
|
field: "lastRun",
|
|
direction: sortableFields["lastRun"].defaultDirection!,
|
|
};
|
|
|
|
@state()
|
|
private filterBy: Partial<{ [k in keyof ListWorkflow]: boolean }> = {};
|
|
|
|
@state()
|
|
private filterByCurrentUser = false;
|
|
|
|
// 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]),
|
|
);
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
this.filterByCurrentUser =
|
|
window.sessionStorage.getItem(FILTER_BY_CURRENT_USER_STORAGE_KEY) ===
|
|
"true";
|
|
}
|
|
|
|
protected async willUpdate(
|
|
changedProperties: PropertyValues<this> & Map<string, unknown>,
|
|
) {
|
|
if (
|
|
changedProperties.has("orderBy") ||
|
|
changedProperties.has("filterByCurrentUser") ||
|
|
changedProperties.has("filterByScheduled") ||
|
|
changedProperties.has("filterBy")
|
|
) {
|
|
void this.fetchWorkflows({
|
|
page: 1,
|
|
});
|
|
}
|
|
if (changedProperties.has("filterByCurrentUser")) {
|
|
window.sessionStorage.setItem(
|
|
FILTER_BY_CURRENT_USER_STORAGE_KEY,
|
|
this.filterByCurrentUser.toString(),
|
|
);
|
|
}
|
|
}
|
|
|
|
protected firstUpdated() {
|
|
void this.fetchConfigSearchValues();
|
|
}
|
|
|
|
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",
|
|
});
|
|
}
|
|
}
|
|
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(
|
|
msg("Crawl Workflows"),
|
|
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>
|
|
`,
|
|
)}
|
|
`,
|
|
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>
|
|
`,
|
|
() =>
|
|
this.workflows
|
|
? this.workflows.total
|
|
? this.renderWorkflowList()
|
|
: this.renderEmptyState()
|
|
: this.renderLoading(),
|
|
)}
|
|
`;
|
|
}
|
|
|
|
private renderControls() {
|
|
return html`
|
|
<div class="mb-2 flex flex-wrap items-center gap-2 md:gap-4">
|
|
<div class="grow">${this.renderSearch()}</div>
|
|
|
|
<div class="flex w-full items-center md:w-fit">
|
|
<div class="text-0-500 mr-2 whitespace-nowrap text-sm">
|
|
${msg("Sort by:")}
|
|
</div>
|
|
<sl-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-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>
|
|
|
|
<div class="flex flex-wrap items-center justify-between">
|
|
<div class="text-sm">
|
|
<button
|
|
class="${this.filterBy.schedule === undefined
|
|
? "border-b-current text-primary"
|
|
: "text-neutral-500"} mr-3 inline-block border-b-2 border-transparent font-medium"
|
|
aria-selected=${this.filterBy.schedule === undefined}
|
|
@click=${() =>
|
|
(this.filterBy = {
|
|
...this.filterBy,
|
|
schedule: undefined,
|
|
})}
|
|
>
|
|
${msg("All")}
|
|
</button>
|
|
<button
|
|
class="${this.filterBy.schedule === true
|
|
? "border-b-current text-primary"
|
|
: "text-neutral-500"} mr-3 inline-block border-b-2 border-transparent font-medium"
|
|
aria-selected=${this.filterBy.schedule === true}
|
|
@click=${() =>
|
|
(this.filterBy = {
|
|
...this.filterBy,
|
|
schedule: true,
|
|
})}
|
|
>
|
|
${msg("Scheduled")}
|
|
</button>
|
|
<button
|
|
class="${this.filterBy.schedule === false
|
|
? "border-b-current text-primary"
|
|
: "text-neutral-500"} mr-3 inline-block border-b-2 border-transparent font-medium"
|
|
aria-selected=${this.filterBy.schedule === false}
|
|
@click=${() =>
|
|
(this.filterBy = {
|
|
...this.filterBy,
|
|
schedule: false,
|
|
})}
|
|
>
|
|
${msg("No schedule")}
|
|
</button>
|
|
</div>
|
|
<div class="flex items-center justify-end">
|
|
<label>
|
|
<span class="mr-1 text-xs text-neutral-500"
|
|
>${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>
|
|
</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>
|
|
${when(
|
|
total > pageSize,
|
|
() => html`
|
|
<footer class="mt-6 flex justify-center">
|
|
<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
|
|
orgSlug=${this.appState.orgSlug || ""}
|
|
.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("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}#watch`,
|
|
{
|
|
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}#watch`,
|
|
{
|
|
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-divider></sl-divider>
|
|
<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=${() => CopyButton.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>
|
|
${when(
|
|
!workflow.lastCrawlId,
|
|
() => html`
|
|
<sl-divider></sl-divider>
|
|
<sl-menu-item
|
|
style="--sl-color-neutral-700: var(--danger)"
|
|
@click=${() => void this.delete(workflow)}
|
|
>
|
|
<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) {
|
|
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 = {};
|
|
}}
|
|
>
|
|
${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 || 1,
|
|
pageSize:
|
|
queryParams?.pageSize ||
|
|
this.workflows?.pageSize ||
|
|
INITIAL_PAGE_SIZE,
|
|
userid: this.filterByCurrentUser ? this.userInfo?.id : undefined,
|
|
sortBy: this.orderBy.field,
|
|
sortDirection: this.orderBy.direction === "desc" ? -1 : 1,
|
|
},
|
|
{
|
|
arrayFormat: "comma",
|
|
},
|
|
);
|
|
|
|
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, seeds] = await Promise.all([
|
|
this.getWorkflow(workflow),
|
|
this.getSeeds(workflow),
|
|
]);
|
|
|
|
const workflowParams: WorkflowParams = {
|
|
...fullWorkflow,
|
|
name: workflow.name ? msg(str`${workflow.name} Copy`) : "",
|
|
};
|
|
|
|
this.navigate.to(`${this.navigate.orgBasePath}/workflows/new`, {
|
|
workflow: workflowParams,
|
|
seeds: seeds.items,
|
|
});
|
|
|
|
if (seeds.total > SEEDS_MAX) {
|
|
this.notify.toast({
|
|
title: msg(str`Partially copied Workflow`),
|
|
message: msg(
|
|
str`Only first ${this.localize.number(SEEDS_MAX)} URLs were copied.`,
|
|
),
|
|
variant: "warning",
|
|
icon: "exclamation-triangle",
|
|
});
|
|
} else {
|
|
this.notify.toast({
|
|
message: msg(str`Copied Workflow to new template.`),
|
|
variant: "success",
|
|
icon: "check2-circle",
|
|
});
|
|
}
|
|
}
|
|
|
|
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",
|
|
});
|
|
} catch {
|
|
this.notify.toast({
|
|
message: msg("Sorry, couldn't delete Workflow at this time."),
|
|
variant: "danger",
|
|
icon: "exclamation-octagon",
|
|
});
|
|
}
|
|
}
|
|
|
|
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",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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}#watch"
|
|
@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",
|
|
});
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|