diff --git a/backend/btrixcloud/crawlconfigs.py b/backend/btrixcloud/crawlconfigs.py
index 9a1f5119b7..e1c9d60b0f 100644
--- a/backend/btrixcloud/crawlconfigs.py
+++ b/backend/btrixcloud/crawlconfigs.py
@@ -21,6 +21,7 @@
from .pagination import DEFAULT_PAGE_SIZE, paginated_format
from .models import (
+ TYPE_ALL_CRAWL_STATES,
CrawlConfigIn,
ConfigRevision,
CrawlConfig,
@@ -732,6 +733,7 @@ async def get_crawl_configs(
description: Optional[str] = None,
tags: Optional[List[str]] = None,
tag_match: Optional[ListFilterType] = ListFilterType.AND,
+ last_crawl_state: list[TYPE_ALL_CRAWL_STATES] | None = None,
schedule: Optional[bool] = None,
is_crawl_running: Optional[bool] = None,
sort_by: str = "lastRun",
@@ -773,6 +775,9 @@ async def get_crawl_configs(
if is_crawl_running is not None:
match_query["isCrawlRunning"] = is_crawl_running
+ if last_crawl_state:
+ match_query["lastCrawlState"] = {"$in": last_crawl_state}
+
# pylint: disable=duplicate-code
aggregate: List[Dict[str, Union[object, str, int]]] = [
{"$match": match_query},
@@ -1567,6 +1572,10 @@ async def get_crawl_configs(
description='Defaults to `"and"` if omitted',
),
] = ListFilterType.AND,
+ last_crawl_state: Annotated[
+ list[TYPE_ALL_CRAWL_STATES] | None,
+ Query(alias="lastCrawlState", title="Last Crawl State"),
+ ] = None,
schedule: Optional[bool] = None,
is_crawl_running: Annotated[
Optional[bool], Query(alias="isCrawlRunning", title="Is Crawl Running")
@@ -1596,6 +1605,7 @@ async def get_crawl_configs(
description=description,
tags=tag,
tag_match=tag_match,
+ last_crawl_state=last_crawl_state,
schedule=schedule,
is_crawl_running=is_crawl_running,
page_size=page_size,
diff --git a/frontend/src/features/archived-items/archived-item-state-filter.ts b/frontend/src/features/archived-items/archived-item-state-filter.ts
index e9bbb9e8d1..541db1e4b7 100644
--- a/frontend/src/features/archived-items/archived-item-state-filter.ts
+++ b/frontend/src/features/archived-items/archived-item-state-filter.ts
@@ -182,7 +182,10 @@ export class ArchivedItemStateFilter extends BtrixElement {
}
private renderLabel(state: CrawlState) {
- const { icon, label } = CrawlStatus.getContent({ state });
+ const { icon, label } = CrawlStatus.getContent({
+ state,
+ originalState: state,
+ });
return html`${icon}${label} {
const checked = this.selected.get(state) === true;
- const { icon, label } = CrawlStatus.getContent({ state });
+ const { icon, label } = CrawlStatus.getContent({
+ state,
+ originalState: state,
+ });
return html`
diff --git a/frontend/src/features/crawl-workflows/index.ts b/frontend/src/features/crawl-workflows/index.ts
index 57a46ba5a5..2dd12c1a8a 100644
--- a/frontend/src/features/crawl-workflows/index.ts
+++ b/frontend/src/features/crawl-workflows/index.ts
@@ -11,3 +11,4 @@ import("./workflow-list");
import("./workflow-schedule-filter");
import("./workflow-tag-filter");
import("./workflow-profile-filter");
+import("./workflow-last-crawl-state-filter");
diff --git a/frontend/src/features/crawl-workflows/workflow-last-crawl-state-filter.ts b/frontend/src/features/crawl-workflows/workflow-last-crawl-state-filter.ts
new file mode 100644
index 0000000000..f3d8aec6d0
--- /dev/null
+++ b/frontend/src/features/crawl-workflows/workflow-last-crawl-state-filter.ts
@@ -0,0 +1,274 @@
+import { localized, msg, str } from "@lit/localize";
+import type {
+ SlChangeEvent,
+ SlCheckbox,
+ SlInput,
+ SlInputEvent,
+} from "@shoelace-style/shoelace";
+import Fuse from "fuse.js";
+import { html, type PropertyValues } from "lit";
+import {
+ customElement,
+ property,
+ query,
+ queryAll,
+ state,
+} from "lit/decorators.js";
+import { repeat } from "lit/directives/repeat.js";
+import { isFocusable } from "tabbable";
+
+import { BtrixElement } from "@/classes/BtrixElement";
+import type { BtrixChangeEvent } from "@/events/btrix-change";
+import { CrawlStatus } from "@/features/archived-items/crawl-status";
+import { CRAWL_STATES, type CrawlState } from "@/types/crawlState";
+import { isNotEqual } from "@/utils/is-not-equal";
+import { tw } from "@/utils/tailwind";
+
+const MAX_STATES_IN_LABEL = 2;
+
+type ChangeWorkflowLastCrawlStateEventDetails = CrawlState[];
+
+export type BtrixChangeWorkflowLastCrawlStateFilterEvent =
+ BtrixChangeEvent;
+
+/**
+ * @fires btrix-change
+ */
+@customElement("btrix-workflow-last-crawl-state-filter")
+@localized()
+export class WorkflowLastCrawlStateFilter extends BtrixElement {
+ @property({ type: Array })
+ states?: CrawlState[] | undefined;
+
+ @state()
+ private searchString = "";
+
+ @query("sl-input")
+ private readonly input?: SlInput | null;
+
+ @queryAll("sl-checkbox")
+ private readonly checkboxes!: NodeListOf;
+
+ private readonly fuse = new Fuse(CRAWL_STATES);
+
+ @state({ hasChanged: isNotEqual })
+ selected = new Map();
+
+ protected willUpdate(changedProperties: PropertyValues): void {
+ if (changedProperties.has("states")) {
+ if (this.states) {
+ this.selected = new Map(this.states.map((state) => [state, true]));
+ } else if (changedProperties.get("states")) {
+ this.selected = new Map();
+ }
+ }
+ }
+
+ protected updated(changedProperties: PropertyValues): void {
+ if (changedProperties.has("selected")) {
+ this.dispatchEvent(
+ new CustomEvent<
+ BtrixChangeEvent["detail"]
+ >("btrix-change", {
+ detail: {
+ value: Array.from(this.selected.entries())
+ .filter(([_tag, selected]) => selected)
+ .map(([tag]) => tag),
+ },
+ }),
+ );
+ }
+ }
+
+ render() {
+ const options = this.searchString
+ ? this.fuse.search(this.searchString)
+ : CRAWL_STATES.map((state) => ({ item: state }));
+ return html`
+ {
+ if (this.input && !this.input.disabled) {
+ this.input.focus();
+ }
+ }}
+ @sl-after-hide=${() => {
+ this.searchString = "";
+ }}
+ >
+ ${this.states?.length
+ ? html`${msg("Latest Crawl Status")}
+ ${this.renderStatesInLabel(this.states)}`
+ : msg("Latest Crawl Status")}
+
+
+
+
+ ${options.length > 0
+ ? this.renderList(options)
+ : html`
+ ${msg("No matching states found.")}
+
`}
+
+
+ `;
+ }
+
+ private renderStatesInLabel(states: string[]) {
+ const formatter = this.localize.list(
+ states.length > MAX_STATES_IN_LABEL
+ ? [
+ ...states.slice(0, MAX_STATES_IN_LABEL),
+ msg(
+ str`${this.localize.number(states.length - MAX_STATES_IN_LABEL)} more`,
+ ),
+ ]
+ : states,
+ { type: "disjunction" },
+ );
+
+ return formatter.map((part, index, array) =>
+ part.type === "literal"
+ ? html`${part.value}`
+ : states.length > MAX_STATES_IN_LABEL && index === array.length - 1
+ ? html` ${part.value} `
+ : html`${this.renderLabel(part.value as CrawlState)}`,
+ );
+ }
+
+ private renderLabel(state: CrawlState) {
+ const { icon, label } = CrawlStatus.getContent({
+ state,
+ originalState: state,
+ });
+ return html`${icon}${label}`;
+ }
+
+ private renderSearch() {
+ return html`
+
+
+ (this.searchString = (e.target as SlInput).value)}
+ @keydown=${(e: KeyboardEvent) => {
+ // Prevent moving to next tabbable element since dropdown should close
+ if (e.key === "Tab") e.preventDefault();
+ if (e.key === "ArrowDown" && isFocusable(this.checkboxes[0])) {
+ this.checkboxes[0].focus();
+ }
+ }}
+ >
+
+
+ `;
+ }
+
+ private renderList(opts: { item: CrawlState }[]) {
+ const state = (state: CrawlState) => {
+ const checked = this.selected.get(state) === true;
+
+ const { icon, label } = CrawlStatus.getContent({
+ state,
+ originalState: state,
+ });
+
+ return html`
+
+
+ ${label}${icon}
+
+
+ `;
+ };
+
+ return html`
+ {
+ const { checked, value } = e.target as SlCheckbox;
+
+ this.selected = new Map([
+ ...this.selected,
+ [value as CrawlState, checked],
+ ]);
+ }}
+ >
+ ${repeat(
+ opts,
+ ({ item }) => item,
+ ({ item }) => state(item),
+ )}
+
+ `;
+ }
+}
diff --git a/frontend/src/pages/org/workflows-list.ts b/frontend/src/pages/org/workflows-list.ts
index 6ab7d4a061..19898f49cc 100644
--- a/frontend/src/pages/org/workflows-list.ts
+++ b/frontend/src/pages/org/workflows-list.ts
@@ -32,6 +32,7 @@ import {
Action,
type BtrixSelectActionEvent,
} from "@/features/crawl-workflows/workflow-action-menu/types";
+import { type BtrixChangeWorkflowLastCrawlStateFilterEvent } from "@/features/crawl-workflows/workflow-last-crawl-state-filter";
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";
@@ -40,6 +41,7 @@ import { WorkflowTab } from "@/routes";
import scopeTypeLabels from "@/strings/crawl-workflows/scopeType";
import { deleteConfirmation } from "@/strings/ui";
import type { APIPaginatedList, APIPaginationQuery } from "@/types/api";
+import { type CrawlState } from "@/types/crawlState";
import {
NewWorkflowOnlyScopeType,
type StorageSeedFile,
@@ -102,6 +104,7 @@ type FilterBy = {
firstSeed?: string;
schedule?: boolean;
isCrawlRunning?: true;
+ lastCrawlState?: CrawlState[];
};
/**
@@ -176,6 +179,7 @@ export class WorkflowsList extends BtrixElement {
"firstSeed",
"schedule",
"isCrawlRunning",
+ "lastCrawlState",
] as (keyof FilterBy)[];
keys.forEach((key) => {
if (value[key] == null) {
@@ -196,12 +200,19 @@ export class WorkflowsList extends BtrixElement {
params.delete(key);
}
break;
+ case "lastCrawlState":
+ params.delete("lastCrawlStatus");
+ value[key].forEach((state) => {
+ params.append("lastCrawlStatus", state);
+ });
+ break;
}
}
});
return params;
},
(params) => {
+ const status = params.getAll("lastCrawlStatus") as CrawlState[];
return {
name: params.get("name") ?? undefined,
firstSeed: params.get("firstSeed") ?? undefined,
@@ -209,6 +220,7 @@ export class WorkflowsList extends BtrixElement {
? params.get("schedule") === "true"
: undefined,
isCrawlRunning: params.get("isCrawlRunning") === "true" || undefined,
+ lastCrawlState: status.length ? status : undefined,
};
},
);
@@ -297,13 +309,7 @@ export class WorkflowsList extends BtrixElement {
}
private clearFilters() {
- this.filterBy.setValue({
- ...this.filterBy.value,
- firstSeed: undefined,
- name: undefined,
- isCrawlRunning: undefined,
- schedule: undefined,
- });
+ this.filterBy.setValue({});
this.filterByCurrentUser.setValue(false);
this.filterByTags.setValue(undefined);
this.filterByProfiles.setValue([]);
@@ -679,6 +685,16 @@ export class WorkflowsList extends BtrixElement {
}}
>
+ {
+ this.filterBy.setValue({
+ ...this.filterBy.value,
+ lastCrawlState: e.detail.value,
+ });
+ }}
+ >
+
{
diff --git a/frontend/src/types/crawlState.ts b/frontend/src/types/crawlState.ts
index 9c56c48d19..6abc45b67b 100644
--- a/frontend/src/types/crawlState.ts
+++ b/frontend/src/types/crawlState.ts
@@ -43,7 +43,7 @@ export const SUCCESSFUL_AND_FAILED_STATES = [
...FAILED_STATES,
] as const;
-const CRAWL_STATES = [
+export const CRAWL_STATES = [
...RUNNING_AND_WAITING_STATES,
...SUCCESSFUL_AND_FAILED_STATES,
] as const;