`;
diff --git a/frontend/src/features/archived-items/delete-item-dialog.ts b/frontend/src/features/archived-items/delete-item-dialog.ts
new file mode 100644
index 0000000000..8d84a455f0
--- /dev/null
+++ b/frontend/src/features/archived-items/delete-item-dialog.ts
@@ -0,0 +1,144 @@
+import { localized, msg, str } from "@lit/localize";
+import { Task } from "@lit/task";
+import { html } from "lit";
+import { customElement, property, query } from "lit/decorators.js";
+import { when } from "lit/directives/when.js";
+
+import { BtrixElement } from "@/classes/BtrixElement";
+import type { Dialog } from "@/components/ui/dialog";
+import type { ArchivedItem } from "@/types/crawler";
+import { isCrawl, renderName } from "@/utils/crawler";
+import { pluralOf } from "@/utils/pluralize";
+
+/**
+ * Confirm deletion of an archived item, crawl associated
+ * with an archived item, or crawl run.
+ *
+ * @slot name
+ */
+@customElement("btrix-delete-item-dialog")
+@localized()
+export class DeleteItemDialog extends BtrixElement {
+ @property({ type: Object })
+ item?: ArchivedItem;
+
+ @property({ type: Boolean })
+ open = false;
+
+ @query("btrix-dialog")
+ readonly dialog?: Dialog | null;
+
+ private readonly collectionsTask = new Task(this, {
+ task: async ([open, crawl], { signal }) => {
+ if (!crawl?.collectionIds) return;
+
+ if (!open) {
+ return crawl.collectionIds.map((id) => ({ id }));
+ }
+
+ return (await this.getCrawl(crawl.id, signal)).collections;
+ },
+ args: () => [this.open, this.item] as const,
+ });
+
+ render() {
+ return html`
+ ${this.renderContent()}
+ `;
+ }
+
+ private renderContent() {
+ const item = this.item;
+
+ if (!item) return;
+
+ const crawl = isCrawl(item);
+ const item_name = html`${renderName(item)}`;
+
+ return html`
+
+ ${msg(html`Are you sure you want to delete ${item_name}?`)}
+ ${msg("All associated files and logs will be deleted.")}
+
+
+ ${this.renderCollections()}
+
+ {
+ void this.dialog?.hide();
+ this.dispatchEvent(new CustomEvent("btrix-cancel"));
+ }}
+ >${msg("Cancel")}
+ {
+ this.dispatchEvent(new CustomEvent("btrix-confirm"));
+ }}
+ >${crawl
+ ? msg("Delete Crawl")
+ : msg("Delete Archived Item")}
+
+ `;
+ }
+
+ private renderCollections() {
+ if (!this.item?.collectionIds.length) return;
+
+ const { collectionIds } = this.item;
+ const count = collectionIds.length;
+
+ const number_of_collections = this.localize.number(count);
+ const plural_of_collections = pluralOf("collections", count);
+
+ return html`
+
+ ${msg(
+ str`The archived item will be removed from ${number_of_collections} ${plural_of_collections}:`,
+ )}
+
+ ${this.collectionsTask.render({
+ pending: () =>
+ html` ({ id }))}
+ baseUrl="${this.navigate.orgBasePath}/collections/view"
+ >
+ `,
+ complete: (res) =>
+ when(
+ res,
+ (collections) =>
+ html`
+ `,
+ ),
+ })}
+ `;
+ }
+
+ private async getCrawl(id: string, signal: AbortSignal) {
+ const data: ArchivedItem = await this.api.fetch(
+ `/orgs/${this.orgId}/crawls/${id}/replay.json`,
+ {
+ signal,
+ },
+ );
+
+ return data;
+ }
+}
diff --git a/frontend/src/features/archived-items/index.ts b/frontend/src/features/archived-items/index.ts
index 4540bac898..702ce361e9 100644
--- a/frontend/src/features/archived-items/index.ts
+++ b/frontend/src/features/archived-items/index.ts
@@ -1,9 +1,9 @@
import("./archived-item-list");
import("./archived-item-state-filter");
import("./archived-item-tag-filter");
-import("./crawl-list");
import("./crawl-log-table");
import("./crawl-logs");
+import("./delete-item-dialog");
import("./item-metadata-editor");
import("./crawl-pending-exclusions");
import("./crawl-queue");
diff --git a/frontend/src/features/crawl-workflows/index.ts b/frontend/src/features/crawl-workflows/index.ts
index 2dd12c1a8a..8a662816b2 100644
--- a/frontend/src/features/crawl-workflows/index.ts
+++ b/frontend/src/features/crawl-workflows/index.ts
@@ -9,6 +9,7 @@ import("./workflow-action-menu/workflow-action-menu");
import("./workflow-editor");
import("./workflow-list");
import("./workflow-schedule-filter");
+import("./workflow-search");
import("./workflow-tag-filter");
import("./workflow-profile-filter");
import("./workflow-last-crawl-state-filter");
diff --git a/frontend/src/features/crawl-workflows/workflow-list.ts b/frontend/src/features/crawl-workflows/workflow-list.ts
index b800d03111..00a9327e81 100644
--- a/frontend/src/features/crawl-workflows/workflow-list.ts
+++ b/frontend/src/features/crawl-workflows/workflow-list.ts
@@ -452,7 +452,12 @@ export class WorkflowListItem extends BtrixElement {
- ${workflow.modifiedByName}
+ ${workflow.modifiedByName
+ ? html``
+ : notSpecified}
${shortDate(workflow.modified)}
diff --git a/frontend/src/features/crawl-workflows/workflow-search.ts b/frontend/src/features/crawl-workflows/workflow-search.ts
new file mode 100644
index 0000000000..c6cfb74314
--- /dev/null
+++ b/frontend/src/features/crawl-workflows/workflow-search.ts
@@ -0,0 +1,26 @@
+import { localized, msg } from "@lit/localize";
+import { customElement, property } from "lit/decorators.js";
+
+import { SearchCombobox } from "@/components/ui/search-combobox";
+
+export type SearchFields = "name" | "firstSeed";
+
+@customElement("btrix-workflow-search")
+@localized()
+export class WorkflowSearch extends SearchCombobox<{ [x: string]: string }> {
+ static FieldLabels: Record = {
+ name: msg("Name"),
+ firstSeed: msg("Crawl Start URL"),
+ };
+
+ @property({ type: Array })
+ searchOptions: { [x: string]: string }[] = [];
+
+ @property({ type: String })
+ selectedKey?: string;
+
+ readonly searchKeys = ["name", "firstSeed"];
+ readonly keyLabels = WorkflowSearch.FieldLabels;
+
+ placeholder = msg("Search by workflow name or crawl start URL");
+}
diff --git a/frontend/src/features/archived-items/crawl-list.ts b/frontend/src/features/crawls/crawl-list/crawl-list-item.ts
similarity index 51%
rename from frontend/src/features/archived-items/crawl-list.ts
rename to frontend/src/features/crawls/crawl-list/crawl-list-item.ts
index b162fa510f..3edd7fe5c4 100644
--- a/frontend/src/features/archived-items/crawl-list.ts
+++ b/frontend/src/features/crawls/crawl-list/crawl-list-item.ts
@@ -1,31 +1,12 @@
-/**
- * Display list of crawls
- *
- * Usage example:
- * ```ts
- *
- *
- *
- *
- *
- *
- * ```
- */
import { localized, msg } from "@lit/localize";
import { css, html, nothing, type TemplateResult } from "lit";
-import {
- customElement,
- property,
- query,
- queryAssignedElements,
-} from "lit/decorators.js";
+import { customElement, property, query } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { BtrixElement } from "@/classes/BtrixElement";
-import { TailwindElement } from "@/classes/TailwindElement";
import type { OverflowDropdown } from "@/components/ui/overflow-dropdown";
import type { Crawl } from "@/types/crawler";
-import { renderName } from "@/utils/crawler";
+import { isSkipped, renderName } from "@/utils/crawler";
import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter";
/**
@@ -99,7 +80,7 @@ export class CrawlListItem extends BtrixElement {
`;
} else {
const label = html`
-
+
${this.safeRender((workflow) => renderName(workflow))}
`;
@@ -114,6 +95,12 @@ export class CrawlListItem extends BtrixElement {
`;
}
+ const skipped = isSkipped(this.crawl);
+ const hasExec = Boolean(this.crawl.crawlExecSeconds);
+ const notApplicable = html`
+
+ `;
+
return html`
`
- : html`---`,
- )}
-
-
- ${this.safeRender((crawl) =>
- this.localize.humanizeDuration(
- (crawl.finished
- ? new Date(crawl.finished)
- : new Date()
- ).valueOf() - new Date(crawl.started).valueOf(),
- ),
+ : notApplicable,
)}
${this.safeRender((crawl) =>
- humanizeExecutionSeconds(crawl.crawlExecSeconds),
+ !skipped
+ ? html`
+ ${humanizeExecutionSeconds(crawl.crawlExecSeconds, {
+ style: "short",
+ })}
+
+ ${humanizeExecutionSeconds(crawl.crawlExecSeconds, {
+ style: "long",
+ })}
+
+ `
+ : notApplicable,
)}
${this.safeRender((crawl) => {
- const pagesFound = +(crawl.stats?.found || 0);
- if (crawl.finished) {
- const pagesComplete = crawl.pageCount ? +crawl.pageCount : 0;
- return `${this.localize.number(pagesComplete, { notation: "compact" })}`;
+ if (hasExec) {
+ const pagesComplete = crawl.finished
+ ? crawl.pageCount
+ ? +crawl.pageCount
+ : 0
+ : +(crawl.stats?.done || 0);
+
+ return this.localize.number(pagesComplete, {
+ notation: "compact",
+ });
}
- const pagesComplete = +(crawl.stats?.done || 0);
- return `${this.localize.number(pagesComplete, { notation: "compact" })} / ${this.localize.number(pagesFound, { notation: "compact" })}`;
+ return notApplicable;
})}
${this.safeRender((crawl) =>
- this.localize.bytes(
- crawl.finished ? crawl.fileSize || 0 : +(crawl.stats?.size || 0),
- {
- unitDisplay: "narrow",
- },
- ),
+ hasExec
+ ? this.localize.bytes(
+ crawl.finished
+ ? crawl.fileSize || 0
+ : +(crawl.stats?.size || 0),
+ )
+ : notApplicable,
)}
-
- ${this.safeRender((crawl) => crawl.userName)}
-
+ ${this.safeRender(
+ (crawl) =>
+ html``,
+ )}
${this.renderActions()}
@@ -248,110 +247,3 @@ export class CrawlListItem extends BtrixElement {
`;
}
}
-
-/**
- * @slot
- */
-@customElement("btrix-crawl-list")
-@localized()
-export class CrawlList extends TailwindElement {
- static styles = css`
- btrix-table {
- --btrix-table-cell-gap: var(--sl-spacing-x-small);
- --btrix-table-cell-padding-x: var(--sl-spacing-small);
- }
-
- btrix-table-body {
- --btrix-table-cell-padding-y: var(--sl-spacing-2x-small);
- }
-
- btrix-table-body ::slotted(*:nth-of-type(n + 2)) {
- --btrix-border-top: 1px solid var(--sl-panel-border-color);
- }
-
- btrix-table-body ::slotted(*:first-of-type) {
- --btrix-border-radius-top: var(--sl-border-radius-medium);
- }
-
- btrix-table-body ::slotted(*:last-of-type) {
- --btrix-border-radius-bottom: var(--sl-border-radius-medium);
- }
- `;
-
- @property({ type: String })
- collectionId?: string;
-
- @property({ type: String })
- workflowId?: string;
-
- @queryAssignedElements({ selector: "btrix-crawl-list-item" })
- listItems!: HTMLElement[];
-
- render() {
- return html`
-
-
-
-
- ${msg("Status")}
-
- ${this.workflowId
- ? nothing
- : html`
-
- ${msg("Name")}
-
- `}
-
- ${msg("Started")}
-
-
- ${msg("Finished")}
-
- ${msg("Run Duration")}
- ${msg("Execution Time")}
- ${msg("Pages")}
- ${msg("Size")}
-
- ${msg("Run By")}
-
-
- ${msg("Row actions")}
-
-
-
-
-
-
- `;
- }
-
- private handleSlotchange() {
- const assignProp = (
- el: HTMLElement,
- attr: { name: string; value: string },
- ) => {
- if (!el.attributes.getNamedItem(attr.name)) {
- el.setAttribute(attr.name, attr.value);
- }
- };
-
- this.listItems.forEach((item) => {
- assignProp(item, {
- name: "collectionId",
- value: this.collectionId || "",
- });
- assignProp(item, { name: "workflowId", value: this.workflowId || "" });
- });
- }
-}
diff --git a/frontend/src/features/crawls/crawl-list/crawl-list.ts b/frontend/src/features/crawls/crawl-list/crawl-list.ts
new file mode 100644
index 0000000000..2b515fc4b5
--- /dev/null
+++ b/frontend/src/features/crawls/crawl-list/crawl-list.ts
@@ -0,0 +1,128 @@
+import { localized, msg } from "@lit/localize";
+import { css, html, nothing } from "lit";
+import {
+ customElement,
+ property,
+ queryAssignedElements,
+} from "lit/decorators.js";
+
+import { TailwindElement } from "@/classes/TailwindElement";
+
+/**
+ * Display list of crawls
+ *
+ * Usage example:
+ * ```ts
+ *
+ *
+ *
+ *
+ *
+ *
+ * ```
+ *
+ * @slot
+ */
+@customElement("btrix-crawl-list")
+@localized()
+export class CrawlList extends TailwindElement {
+ static styles = css`
+ btrix-table {
+ --btrix-table-cell-gap: var(--sl-spacing-x-small);
+ --btrix-table-cell-padding-x: var(--sl-spacing-small);
+ }
+
+ btrix-table-body {
+ --btrix-table-cell-padding-y: var(--sl-spacing-2x-small);
+ }
+
+ btrix-table-body ::slotted(*:nth-of-type(n + 2)) {
+ --btrix-border-top: 1px solid var(--sl-panel-border-color);
+ }
+
+ btrix-table-body ::slotted(*:first-of-type) {
+ --btrix-border-radius-top: var(--sl-border-radius-medium);
+ }
+
+ btrix-table-body ::slotted(*:last-of-type) {
+ --btrix-border-radius-bottom: var(--sl-border-radius-medium);
+ }
+ `;
+
+ @property({ type: String })
+ collectionId?: string;
+
+ @property({ type: String })
+ workflowId?: string;
+
+ @queryAssignedElements({ selector: "btrix-crawl-list-item" })
+ listItems!: HTMLElement[];
+
+ render() {
+ const columns = [
+ "min-content [clickable-start]",
+ this.workflowId ? undefined : "36ch", // Name
+ "22ch", // Started
+ "22ch", // Finished
+ "auto", // Execution time
+ "auto", // Pages
+ "auto", // Size
+ "[clickable-end] 20ch", // Run by
+ "min-content",
+ ]
+ .filter((v) => v)
+ .join(" ");
+
+ return html`
+
+
+
+ ${msg("Status")}
+
+ ${this.workflowId
+ ? nothing
+ : html`
+
+ ${msg("Name")}
+
+ `}
+ ${msg("Started")}
+
+ ${msg("Finished")}
+
+ ${msg("Execution Time")}
+ ${msg("Pages")}
+ ${msg("Size")}
+ ${msg("Run By")}
+
+ ${msg("Row actions")}
+
+
+
+
+
+
+ `;
+ }
+
+ private handleSlotchange() {
+ const assignProp = (
+ el: HTMLElement,
+ attr: { name: string; value: string },
+ ) => {
+ if (!el.attributes.getNamedItem(attr.name)) {
+ el.setAttribute(attr.name, attr.value);
+ }
+ };
+
+ this.listItems.forEach((item) => {
+ assignProp(item, {
+ name: "collectionId",
+ value: this.collectionId || "",
+ });
+ assignProp(item, { name: "workflowId", value: this.workflowId || "" });
+ });
+ }
+}
diff --git a/frontend/src/features/crawls/crawl-list/index.ts b/frontend/src/features/crawls/crawl-list/index.ts
new file mode 100644
index 0000000000..39e87f9901
--- /dev/null
+++ b/frontend/src/features/crawls/crawl-list/index.ts
@@ -0,0 +1,2 @@
+import "./crawl-list";
+import "./crawl-list-item";
diff --git a/frontend/src/features/crawls/index.ts b/frontend/src/features/crawls/index.ts
new file mode 100644
index 0000000000..1e525d2ee6
--- /dev/null
+++ b/frontend/src/features/crawls/index.ts
@@ -0,0 +1 @@
+import("./crawl-list");
diff --git a/frontend/src/features/index.ts b/frontend/src/features/index.ts
index 635b4a9669..bd047b5664 100644
--- a/frontend/src/features/index.ts
+++ b/frontend/src/features/index.ts
@@ -1,9 +1,11 @@
import "./accounts";
import "./archived-items";
import "./browser-profiles";
+import "./crawls";
import "./collections";
import "./crawl-workflows";
import "./org";
import "./qa";
+import "./users";
import("./admin");
diff --git a/frontend/src/features/users/index.ts b/frontend/src/features/users/index.ts
new file mode 100644
index 0000000000..f1e6ac3b44
--- /dev/null
+++ b/frontend/src/features/users/index.ts
@@ -0,0 +1 @@
+import("./user-chip");
diff --git a/frontend/src/features/users/user-chip.ts b/frontend/src/features/users/user-chip.ts
new file mode 100644
index 0000000000..9fb94d2cdb
--- /dev/null
+++ b/frontend/src/features/users/user-chip.ts
@@ -0,0 +1,29 @@
+import { localized } from "@lit/localize";
+import { html } from "lit";
+import { customElement, property } from "lit/decorators.js";
+
+import { BtrixElement } from "@/classes/BtrixElement";
+
+@customElement("btrix-user-chip")
+@localized()
+export class UserChip extends BtrixElement {
+ @property({ type: String })
+ userId?: string;
+
+ @property({ type: String })
+ userName?: string;
+
+ render() {
+ if (!this.userName) return;
+
+ return html`
+ ${this.userName}
+ `;
+ }
+}
diff --git a/frontend/src/index.ts b/frontend/src/index.ts
index f159b684da..0a3674c32d 100644
--- a/frontend/src/index.ts
+++ b/frontend/src/index.ts
@@ -863,7 +863,8 @@ export class App extends BtrixElement {
const orgTab = pathname
.slice(pathname.indexOf(slug) + slug.length)
.replace(/(^\/|\/$)/, "")
- .split("/")[0];
+ .split("/")[0]
+ .split("?")[0];
return html` (this.openDialogName = undefined)}
@updated=${() => void this.fetchCrawl()}
>
+
+ (this.openDialogName = undefined)}
+ @btrix-confirm=${() => {
+ this.openDialogName = undefined;
+ void this.deleteCrawl();
+ }}
+ >
+ ${this.item?.finished && isCrawl(this.item)
+ ? html`${renderName(this.item)}
+ (${this.localize.date(this.item.finished)})`
+ : nothing}
+
`;
}
@@ -508,7 +525,7 @@ export class ArchivedItemDetail extends BtrixElement {
breadcrumbs.push(
{
href: `${this.navigate.orgBasePath}/workflows`,
- content: msg("Crawl Workflows"),
+ content: msg("Workflows"),
},
{
href: `${this.navigate.orgBasePath}/workflows/${this.item?.cid}`,
@@ -768,8 +785,14 @@ export class ArchivedItemDetail extends BtrixElement {
() => html`
void this.deleteCrawl()}
+ class="menu-item-danger"
+ @click=${() => {
+ if (isSuccess) {
+ this.openDialogName = "delete";
+ } else {
+ void this.deleteCrawl();
+ }
+ }}
>
${isWorkflowCrawl
@@ -1362,14 +1385,7 @@ export class ArchivedItemDetail extends BtrixElement {
return !formEl.querySelector("[data-invalid]");
}
- // TODO replace with in-page dialog
private async deleteCrawl() {
- if (
- !window.confirm(msg(str`Are you sure you want to delete this crawl?`))
- ) {
- return;
- }
-
try {
const _data = await this.api.fetch(
`/orgs/${this.item!.oid}/${
diff --git a/frontend/src/pages/org/archived-items.ts b/frontend/src/pages/org/archived-items.ts
index c273895958..7ce6f8586a 100644
--- a/frontend/src/pages/org/archived-items.ts
+++ b/frontend/src/pages/org/archived-items.ts
@@ -1,6 +1,6 @@
import { localized, msg, str } from "@lit/localize";
import { Task } from "@lit/task";
-import type { SlButton, SlSelect } from "@shoelace-style/shoelace";
+import type { SlSelect } from "@shoelace-style/shoelace";
import { html, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
@@ -11,7 +11,6 @@ import queryString from "query-string";
import type { ArchivedItem, Crawl, Workflow } from "./types";
import { BtrixElement } from "@/classes/BtrixElement";
-import type { Dialog } from "@/components/ui/dialog";
import {
type BtrixFilterChipChangeEvent,
type FilterChip,
@@ -33,7 +32,9 @@ import { isApiError } from "@/utils/api";
import {
finishedCrawlStates,
isActive,
+ isCrawl,
isSuccessfullyFinished,
+ renderName,
} from "@/utils/crawler";
import { isArchivingDisabled } from "@/utils/orgs";
import { tw } from "@/utils/tailwind";
@@ -101,10 +102,9 @@ type FilterBy = {
};
/**
- * Usage:
- * ```ts
- *
- * ```
+ * An archived item can be any replayable crawl (i.e. crawl with WACZ files)
+ * or an uploaded WACZ. Users can view, search, and filter archived items
+ * from this page.
*/
@customElement("btrix-archived-items")
@localized()
@@ -415,17 +415,18 @@ export class CrawlsList extends BtrixElement {
}[] = [
{
itemType: null,
- label: msg("All"),
+ icon: "file-zip-fill",
+ label: msg("All Items"),
},
{
itemType: "crawl",
icon: "gear-wide-connected",
- label: msg("Crawls"),
+ label: msg("Crawled Items"),
},
{
itemType: "upload",
icon: "upload",
- label: msg("Uploads"),
+ label: msg("Uploaded Items"),
},
];
@@ -458,8 +459,7 @@ export class CrawlsList extends BtrixElement {
${listTypes.map(({ label, itemType, icon }) => {
const isSelected = itemType === this.itemType;
return html` (this.isDeletingItem = false)}
+ @btrix-confirm=${async () => {
+ this.isDeletingItem = false;
+ if (this.itemToDelete) {
+ await this.deleteItem(this.itemToDelete);
+ }
+ }}
>
- ${msg("This item will be removed from any Collection it is a part of.")}
- ${when(this.itemToDelete?.type === "crawl", () =>
- msg(
- "All files and logs associated with this item will also be deleted, and the crawl will no longer be visible in its associated Workflow.",
- ),
- )}
-
-
- void (e.currentTarget as SlButton)
- .closest
- {
- this.isDeletingItem = false;
- if (this.itemToDelete) {
- await this.deleteItem(this.itemToDelete);
- }
- }}
- >${msg(
- str`Delete ${
- this.itemToDelete?.type === "upload"
- ? msg("Upload")
- : msg("Crawl")
- }`,
- )}
-
-
+ ${this.itemToDelete?.finished && isCrawl(this.itemToDelete)
+ ? html`${renderName(this.itemToDelete)}
+ (${this.localize.date(this.itemToDelete.finished)})`
+ : nothing}
+
`;
private renderControls() {
@@ -858,7 +836,7 @@ export class CrawlsList extends BtrixElement {
() => html`