- Updates superadmin "Running Crawls" to show active crawls (starting, waiting, running, stopping) and sort by start by default - Navigates to crawl workflow watch view on clicking crawl item - Adds "Copy Crawl ID" to crawl actions for easy paste into "Jump to crawl" - Navigates to crawl workflow watch when jumping to crawl
542 lines
14 KiB
TypeScript
542 lines
14 KiB
TypeScript
/**
|
|
* Display list of crawls
|
|
*
|
|
* Usage example:
|
|
* ```ts
|
|
* <btrix-crawl-list>
|
|
* <btrix-crawl-list-item .crawl=${crawl1}>
|
|
* </btrix-crawl-list-item>
|
|
* <btrix-crawl-list-item .crawl=${crawl2}>
|
|
* </btrix-crawl-list-item>
|
|
* </btrix-crawl-list>
|
|
* ```
|
|
*/
|
|
import { LitElement, html, css } from "lit";
|
|
import {
|
|
property,
|
|
query,
|
|
queryAssignedElements,
|
|
state,
|
|
} from "lit/decorators.js";
|
|
import { when } from "lit/directives/when.js";
|
|
import { msg, localized, str } from "@lit/localize";
|
|
import type { SlMenu } from "@shoelace-style/shoelace";
|
|
|
|
import type { Button } from "./button";
|
|
import { RelativeDuration } from "./relative-duration";
|
|
import type { Crawl } from "../types/crawler";
|
|
import { srOnly, truncate, dropdown } from "../utils/css";
|
|
import type { NavigateEvent } from "../utils/LiteElement";
|
|
import { isActive } from "../utils/crawler";
|
|
|
|
const mediumBreakpointCss = css`30rem`;
|
|
const largeBreakpointCss = css`60rem`;
|
|
const rowCss = css`
|
|
.row {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
@media only screen and (min-width: ${mediumBreakpointCss}) {
|
|
.row {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
}
|
|
@media only screen and (min-width: ${largeBreakpointCss}) {
|
|
.row {
|
|
grid-template-columns: 1fr 15rem 10rem 7rem 3rem;
|
|
grid-gap: var(--sl-spacing-x-large);
|
|
}
|
|
}
|
|
|
|
.col {
|
|
grid-column: span 1 / span 1;
|
|
}
|
|
`;
|
|
const columnCss = css`
|
|
.col:not(.action) {
|
|
padding-left: var(--sl-spacing-small);
|
|
padding-right: var(--sl-spacing-small);
|
|
}
|
|
|
|
.col:first-child {
|
|
padding-left: var(--sl-spacing-medium);
|
|
}
|
|
`;
|
|
// Shared custom variables
|
|
const hostVars = css`
|
|
:host {
|
|
--row-offset: var(--sl-spacing-x-small);
|
|
}
|
|
`;
|
|
|
|
@localized()
|
|
export class CrawlListItem extends LitElement {
|
|
static styles = [
|
|
truncate,
|
|
dropdown,
|
|
rowCss,
|
|
columnCss,
|
|
hostVars,
|
|
css`
|
|
a {
|
|
all: unset;
|
|
}
|
|
|
|
.item {
|
|
color: var(--sl-color-neutral-700);
|
|
cursor: pointer;
|
|
overflow: hidden;
|
|
}
|
|
|
|
@media only screen and (min-width: ${largeBreakpointCss}) {
|
|
.item {
|
|
height: 2.5rem;
|
|
}
|
|
}
|
|
|
|
.dropdown {
|
|
contain: content;
|
|
position: absolute;
|
|
z-index: 99;
|
|
}
|
|
|
|
.col {
|
|
display: flex;
|
|
align-items: center;
|
|
transition-property: margin;
|
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
transition-duration: 150ms;
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.detail {
|
|
font-size: var(--sl-font-size-medium);
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.desc {
|
|
font-size: var(--sl-font-size-x-small);
|
|
font-family: var(--font-monostyle-family);
|
|
font-variation-settings: var(--font-monostyle-variation);
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.desc:nth-child(2) {
|
|
margin-left: 1rem;
|
|
color: var(--sl-color-neutral-400);
|
|
}
|
|
|
|
.unknownValue {
|
|
color: var(--sl-color-neutral-400);
|
|
}
|
|
|
|
.detail btrix-crawl-status {
|
|
display: flex;
|
|
}
|
|
|
|
.url {
|
|
display: flex;
|
|
}
|
|
|
|
.url .primaryUrl {
|
|
flex: 0 1 auto;
|
|
}
|
|
|
|
.url .additionalUrls {
|
|
flex: none;
|
|
margin-left: var(--sl-spacing-2x-small);
|
|
}
|
|
|
|
.primaryUrl {
|
|
word-break: break-all;
|
|
}
|
|
|
|
.additionalUrls {
|
|
color: var(--sl-color-neutral-500);
|
|
}
|
|
|
|
.fileSize {
|
|
min-width: 4em;
|
|
}
|
|
|
|
.userName {
|
|
font-family: var(--font-monostyle-family);
|
|
font-variation-settings: var(--font-monostyle-variation);
|
|
}
|
|
|
|
.action {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.action sl-icon-button {
|
|
font-size: 1rem;
|
|
}
|
|
`,
|
|
];
|
|
|
|
@property({ type: Object })
|
|
crawl?: Crawl;
|
|
|
|
@property({ type: String })
|
|
baseUrl?: string;
|
|
|
|
@query(".row")
|
|
row!: HTMLElement;
|
|
|
|
// TODO consolidate with btrix-combobox
|
|
@query(".dropdown")
|
|
dropdown!: HTMLElement;
|
|
|
|
@query(".dropdownTrigger")
|
|
dropdownTrigger!: Button;
|
|
|
|
@queryAssignedElements({ selector: "sl-menu", slot: "menu" })
|
|
private menuArr!: Array<SlMenu>;
|
|
|
|
@state()
|
|
private dropdownIsOpen?: boolean;
|
|
|
|
// TODO localize
|
|
private numberFormatter = new Intl.NumberFormat(undefined, {
|
|
notation: "compact",
|
|
});
|
|
|
|
willUpdate(changedProperties: Map<string, any>) {
|
|
if (changedProperties.has("dropdownIsOpen")) {
|
|
if (this.dropdownIsOpen) {
|
|
this.openDropdown();
|
|
} else {
|
|
this.closeDropdown();
|
|
}
|
|
}
|
|
}
|
|
|
|
render() {
|
|
return html`${this.renderRow()}${this.renderDropdown()}`;
|
|
}
|
|
|
|
renderRow() {
|
|
const hash = this.crawl && isActive(this.crawl.state) ? "#watch" : "";
|
|
return html`<a
|
|
class="item row"
|
|
role="button"
|
|
href=${`${this.baseUrl || `/orgs/${this.crawl?.oid}/artifacts/crawl`}/${
|
|
this.crawl?.id
|
|
}${hash}`}
|
|
@click=${async (e: MouseEvent) => {
|
|
e.preventDefault();
|
|
await this.updateComplete;
|
|
const href = (e.currentTarget as HTMLAnchorElement).href;
|
|
// TODO consolidate with LiteElement navTo
|
|
const evt: NavigateEvent = new CustomEvent("navigate", {
|
|
detail: { url: href },
|
|
bubbles: true,
|
|
composed: true,
|
|
});
|
|
this.dispatchEvent(evt);
|
|
}}
|
|
>
|
|
<div class="col">
|
|
<div class="detail url truncate">
|
|
${this.safeRender(
|
|
(workflow) => html`
|
|
<btrix-crawl-status
|
|
state=${workflow.state}
|
|
hideLabel
|
|
></btrix-crawl-status>
|
|
<slot name="id">${this.renderName(workflow)}</slot>
|
|
`
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div class="col">
|
|
<div class="desc">
|
|
${this.safeRender((crawl) =>
|
|
crawl.finished
|
|
? html`
|
|
<sl-format-date
|
|
date=${`${crawl.finished}Z`}
|
|
month="2-digit"
|
|
day="2-digit"
|
|
year="2-digit"
|
|
hour="2-digit"
|
|
minute="2-digit"
|
|
></sl-format-date>
|
|
`
|
|
: msg(
|
|
str`Running for ${RelativeDuration.humanize(
|
|
new Date().valueOf() -
|
|
new Date(`${crawl.started}Z`).valueOf()
|
|
)}`
|
|
)
|
|
)}
|
|
</div>
|
|
${this.safeRender((crawl) =>
|
|
crawl.finished
|
|
? html`<div class="desc truncate">
|
|
${msg(
|
|
str`in ${RelativeDuration.humanize(
|
|
new Date(`${crawl.finished}Z`).valueOf() -
|
|
new Date(`${crawl.started}Z`).valueOf()
|
|
)}`
|
|
)}
|
|
</div>`
|
|
: ""
|
|
)}
|
|
</div>
|
|
<div class="col">
|
|
${this.safeRender((crawl) => {
|
|
if (crawl.finished) {
|
|
return html`<div class="desc fileSize">
|
|
<sl-format-bytes
|
|
value=${crawl.fileSize || 0}
|
|
display="narrow"
|
|
></sl-format-bytes>
|
|
</div>`;
|
|
}
|
|
const pagesComplete = +(crawl.stats?.done || 0);
|
|
const pagesFound = +(crawl.stats?.found || 0);
|
|
return html` <div class="desc">
|
|
${pagesFound === 1
|
|
? msg(
|
|
str`${this.numberFormatter.format(
|
|
pagesComplete
|
|
)} / ${this.numberFormatter.format(pagesFound)} page`
|
|
)
|
|
: msg(
|
|
str`${this.numberFormatter.format(
|
|
pagesComplete
|
|
)} / ${this.numberFormatter.format(pagesFound)} pages`
|
|
)}
|
|
</div>`;
|
|
})}
|
|
${this.safeRender((crawl) => {
|
|
if (crawl.finished) {
|
|
const pagesComplete = +(crawl.stats?.done || 0);
|
|
return html`
|
|
<div class="desc pages truncate">
|
|
${pagesComplete === 1
|
|
? msg(str`${this.numberFormatter.format(pagesComplete)} page`)
|
|
: msg(
|
|
str`${this.numberFormatter.format(pagesComplete)} pages`
|
|
)}
|
|
</div>
|
|
`;
|
|
}
|
|
return "";
|
|
})}
|
|
</div>
|
|
<div class="col">
|
|
<div class="detail truncate">
|
|
${this.safeRender(
|
|
(crawl) => html`<span class="userName">${crawl.userName}</span>`
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div class="col action">
|
|
<slot name="menuTrigger">
|
|
<btrix-button
|
|
class="dropdownTrigger"
|
|
label=${msg("Actions")}
|
|
icon
|
|
@click=${(e: MouseEvent) => {
|
|
// Prevent anchor link default behavior
|
|
e.preventDefault();
|
|
// Stop prop to anchor link
|
|
e.stopPropagation();
|
|
this.dropdownIsOpen = !this.dropdownIsOpen;
|
|
}}
|
|
@focusout=${(e: FocusEvent) => {
|
|
const relatedTarget = e.relatedTarget as HTMLElement;
|
|
if (relatedTarget) {
|
|
if (this.menuArr[0]?.contains(relatedTarget)) {
|
|
// Keep dropdown open if moving to menu selection
|
|
return;
|
|
}
|
|
if (this.row?.isEqualNode(relatedTarget)) {
|
|
// Handle with click event
|
|
return;
|
|
}
|
|
}
|
|
this.dropdownIsOpen = false;
|
|
}}
|
|
>
|
|
<sl-icon name="three-dots-vertical"></sl-icon>
|
|
</btrix-button>
|
|
</slot>
|
|
</div>
|
|
</a>`;
|
|
}
|
|
|
|
private renderDropdown() {
|
|
return html`<div
|
|
class="dropdown hidden"
|
|
aria-hidden=${!this.dropdownIsOpen}
|
|
@animationend=${(e: AnimationEvent) => {
|
|
const el = e.target as HTMLDivElement;
|
|
if (e.animationName === "dropdownShow") {
|
|
el.classList.remove("animateShow");
|
|
}
|
|
if (e.animationName === "dropdownHide") {
|
|
el.classList.add("hidden");
|
|
el.classList.remove("animateHide");
|
|
}
|
|
}}
|
|
>
|
|
<slot
|
|
name="menu"
|
|
@sl-select=${() => (this.dropdownIsOpen = false)}
|
|
></slot>
|
|
</div> `;
|
|
}
|
|
|
|
private safeRender(render: (crawl: Crawl) => any) {
|
|
if (!this.crawl) {
|
|
return html`<sl-skeleton></sl-skeleton>`;
|
|
}
|
|
return render(this.crawl);
|
|
}
|
|
|
|
private renderName(crawl: Crawl) {
|
|
if (crawl.name) return html`<span class="truncate">${crawl.name}</span>`;
|
|
if (!crawl.firstSeed)
|
|
return html`<span class="truncate">${crawl.id}</span>`;
|
|
const remainder = crawl.seedCount - 1;
|
|
let nameSuffix: any = "";
|
|
if (remainder) {
|
|
if (remainder === 1) {
|
|
nameSuffix = html`<span class="additionalUrls"
|
|
>${msg(str`+${remainder} URL`)}</span
|
|
>`;
|
|
} else {
|
|
nameSuffix = html`<span class="additionalUrls"
|
|
>${msg(str`+${remainder} URLs`)}</span
|
|
>`;
|
|
}
|
|
}
|
|
return html`
|
|
<span class="primaryUrl truncate">${crawl.firstSeed}</span>${nameSuffix}
|
|
`;
|
|
}
|
|
|
|
private repositionDropdown() {
|
|
const { x, y } = this.dropdownTrigger.getBoundingClientRect();
|
|
this.dropdown.style.left = `${x + window.scrollX}px`;
|
|
this.dropdown.style.top = `${y + window.scrollY - 8}px`;
|
|
}
|
|
|
|
private openDropdown() {
|
|
this.repositionDropdown();
|
|
this.dropdown.classList.add("animateShow");
|
|
this.dropdown.classList.remove("hidden");
|
|
}
|
|
|
|
private closeDropdown() {
|
|
this.dropdown.classList.add("animateHide");
|
|
}
|
|
}
|
|
|
|
@localized()
|
|
export class CrawlList extends LitElement {
|
|
static styles = [
|
|
srOnly,
|
|
rowCss,
|
|
columnCss,
|
|
hostVars,
|
|
css`
|
|
.listHeader, .list {
|
|
margin-left: var(--row-offset);
|
|
margin-right: var(--row-offset);
|
|
}
|
|
|
|
.listHeader {
|
|
line-height: 1;
|
|
}
|
|
|
|
.list {
|
|
border: 1px solid var(--sl-panel-border-color);
|
|
border-radius: var(--sl-border-radius-medium);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.row {
|
|
display none;
|
|
font-size: var(--sl-font-size-x-small);
|
|
color: var(--sl-color-neutral-600);
|
|
}
|
|
|
|
.col {
|
|
padding-top: var(--sl-spacing-x-small);
|
|
padding-bottom: var(--sl-spacing-x-small);
|
|
}
|
|
|
|
@media only screen and (min-width: ${largeBreakpointCss}) {
|
|
.row {
|
|
display: grid;
|
|
}
|
|
}
|
|
|
|
::slotted(btrix-crawl-list-item) {
|
|
display: block;
|
|
transition-property: background-color, box-shadow;
|
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
transition-duration: 150ms;
|
|
}
|
|
|
|
::slotted(btrix-crawl-list-item:not(:first-of-type)) {
|
|
box-shadow: inset 0px 1px 0 var(--sl-panel-border-color);
|
|
}
|
|
|
|
::slotted(btrix-crawl-list-item:hover),
|
|
::slotted(btrix-crawl-list-item:focus),
|
|
::slotted(btrix-crawl-list-item:focus-within) {
|
|
background-color: var(--sl-color-neutral-50);
|
|
}
|
|
`,
|
|
];
|
|
|
|
@property({ type: String, noAccessor: true })
|
|
baseUrl?: string;
|
|
|
|
@queryAssignedElements({ selector: "btrix-crawl-list-item" })
|
|
listItems!: Array<HTMLElement>;
|
|
|
|
render() {
|
|
return html` <div class="listHeader row">
|
|
<div class="col">
|
|
<slot name="idCol">${msg("Workflow")}</slot>
|
|
</div>
|
|
<div class="col">${msg("Finished")}</div>
|
|
<div class="col">${msg("Size")}</div>
|
|
<div class="col">${msg("Started By")}</div>
|
|
<div class="col action">
|
|
<span class="srOnly">${msg("Actions")}</span>
|
|
</div>
|
|
</div>
|
|
<div class="list" role="list">
|
|
<slot @slotchange=${this.handleSlotchange}></slot>
|
|
</div>`;
|
|
}
|
|
|
|
private handleSlotchange() {
|
|
const assignRole = (el: HTMLElement) => {
|
|
if (!el.attributes.getNamedItem("role")) {
|
|
el.setAttribute("role", "listitem");
|
|
}
|
|
};
|
|
let mapFn = assignRole;
|
|
if (this.baseUrl) {
|
|
mapFn = (el) => {
|
|
assignRole(el);
|
|
el.setAttribute("baseUrl", this.baseUrl!);
|
|
};
|
|
}
|
|
|
|
this.listItems.forEach(mapFn);
|
|
}
|
|
}
|