Update crawls list control bar UI (#611)
This commit is contained in:
parent
ed94dde7e6
commit
974aeb5e93
@ -1,7 +1,13 @@
|
|||||||
|
import type { TemplateResult } from "lit";
|
||||||
import { state, property } from "lit/decorators.js";
|
import { state, property } from "lit/decorators.js";
|
||||||
import { ifDefined } from "lit/directives/if-defined.js";
|
import { ifDefined } from "lit/directives/if-defined.js";
|
||||||
import { msg, localized, str } from "@lit/localize";
|
import { msg, localized, str } from "@lit/localize";
|
||||||
import { when } from "lit/directives/when.js";
|
import { when } from "lit/directives/when.js";
|
||||||
|
import type {
|
||||||
|
SlCheckbox,
|
||||||
|
SlMenuItem,
|
||||||
|
SlSelect,
|
||||||
|
} from "@shoelace-style/shoelace";
|
||||||
import debounce from "lodash/fp/debounce";
|
import debounce from "lodash/fp/debounce";
|
||||||
import flow from "lodash/fp/flow";
|
import flow from "lodash/fp/flow";
|
||||||
import map from "lodash/fp/map";
|
import map from "lodash/fp/map";
|
||||||
@ -12,33 +18,89 @@ import { CopyButton } from "../../components/copy-button";
|
|||||||
import { RelativeDuration } from "../../components/relative-duration";
|
import { RelativeDuration } from "../../components/relative-duration";
|
||||||
import type { AuthState } from "../../utils/AuthService";
|
import type { AuthState } from "../../utils/AuthService";
|
||||||
import LiteElement, { html } from "../../utils/LiteElement";
|
import LiteElement, { html } from "../../utils/LiteElement";
|
||||||
import type { Crawl, CrawlConfig, InitialCrawlConfig } from "./types";
|
import type {
|
||||||
import { SlCheckbox } from "@shoelace-style/shoelace";
|
Crawl,
|
||||||
|
CrawlState,
|
||||||
|
CrawlConfig,
|
||||||
|
InitialCrawlConfig,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
type CrawlSearchResult = {
|
type CrawlSearchResult = {
|
||||||
item: Crawl;
|
item: Crawl;
|
||||||
};
|
};
|
||||||
|
type SortField = "started" | "finished" | "configName" | "fileSize";
|
||||||
|
type SortDirection = "asc" | "desc";
|
||||||
|
|
||||||
const POLL_INTERVAL_SECONDS = 10;
|
const POLL_INTERVAL_SECONDS = 10;
|
||||||
const MIN_SEARCH_LENGTH = 2;
|
const MIN_SEARCH_LENGTH = 2;
|
||||||
const sortableFieldLabels = {
|
const sortableFields: Record<
|
||||||
started_desc: msg("Newest"),
|
SortField,
|
||||||
started_asc: msg("Oldest"),
|
{ label: string; defaultDirection?: SortDirection }
|
||||||
finished_desc: msg("Recently Updated"),
|
> = {
|
||||||
finished_asc: msg("Oldest Finished"),
|
started: {
|
||||||
state: msg("Status"),
|
label: msg("Date Created"),
|
||||||
configName: msg("Crawl Name"),
|
defaultDirection: "desc",
|
||||||
cid: msg("Crawl Config ID"),
|
},
|
||||||
fileSize_asc: msg("Smallest Files"),
|
finished: {
|
||||||
fileSize_desc: msg("Largest Files"),
|
label: msg("Date Completed"),
|
||||||
|
defaultDirection: "desc",
|
||||||
|
},
|
||||||
|
configName: {
|
||||||
|
label: msg("Crawl Name"),
|
||||||
|
defaultDirection: "desc",
|
||||||
|
},
|
||||||
|
fileSize: {
|
||||||
|
label: msg("File Size"),
|
||||||
|
defaultDirection: "desc",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const activeCrawlStates: CrawlState[] = ["starting", "running", "stopping"];
|
||||||
|
const inactiveCrawlStates: CrawlState[] = [
|
||||||
|
"complete",
|
||||||
|
"canceled",
|
||||||
|
"partial_complete",
|
||||||
|
"timed_out",
|
||||||
|
"failed",
|
||||||
|
];
|
||||||
|
const crawlState: Record<CrawlState, { label: string; icon?: TemplateResult }> =
|
||||||
|
{
|
||||||
|
starting: {
|
||||||
|
label: msg("Starting"),
|
||||||
|
icon: html``,
|
||||||
|
},
|
||||||
|
running: {
|
||||||
|
label: msg("Running"),
|
||||||
|
icon: html``,
|
||||||
|
},
|
||||||
|
complete: {
|
||||||
|
label: msg("Completed"),
|
||||||
|
icon: html``,
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
label: msg("Failed"),
|
||||||
|
icon: html``,
|
||||||
|
},
|
||||||
|
partial_complete: {
|
||||||
|
label: msg("Partial Complete"),
|
||||||
|
icon: html``,
|
||||||
|
},
|
||||||
|
timed_out: {
|
||||||
|
label: msg("Timed Out"),
|
||||||
|
icon: html``,
|
||||||
|
},
|
||||||
|
stopping: {
|
||||||
|
label: msg("Stopping"),
|
||||||
|
icon: html``,
|
||||||
|
},
|
||||||
|
canceled: {
|
||||||
|
label: msg("Canceled"),
|
||||||
|
icon: html``,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function isActive(crawl: Crawl) {
|
function isActive(crawl: Crawl) {
|
||||||
return (
|
return activeCrawlStates.includes(crawl.state);
|
||||||
crawl.state === "running" ||
|
|
||||||
crawl.state === "starting" ||
|
|
||||||
crawl.state === "stopping"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -78,8 +140,8 @@ export class CrawlsList extends LiteElement {
|
|||||||
|
|
||||||
@state()
|
@state()
|
||||||
private orderBy: {
|
private orderBy: {
|
||||||
field: "started";
|
field: SortField;
|
||||||
direction: "asc" | "desc";
|
direction: SortDirection;
|
||||||
} = {
|
} = {
|
||||||
field: "started",
|
field: "started",
|
||||||
direction: "desc",
|
direction: "desc",
|
||||||
@ -89,12 +151,16 @@ export class CrawlsList extends LiteElement {
|
|||||||
private filterByCurrentUser = true;
|
private filterByCurrentUser = true;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private filterBy: string = "";
|
private filterByState: CrawlState[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private searchBy: string = "";
|
||||||
|
|
||||||
// For fuzzy search:
|
// For fuzzy search:
|
||||||
private fuse = new Fuse([], {
|
private fuse = new Fuse([], {
|
||||||
keys: ["cid", "configName"],
|
keys: ["cid", "configName"],
|
||||||
shouldSort: false,
|
shouldSort: false,
|
||||||
|
threshold: 0.4, // stricter; default is 0.6
|
||||||
});
|
});
|
||||||
|
|
||||||
private timerId?: number;
|
private timerId?: number;
|
||||||
@ -102,11 +168,19 @@ export class CrawlsList extends LiteElement {
|
|||||||
// TODO localize
|
// TODO localize
|
||||||
private numberFormatter = new Intl.NumberFormat();
|
private numberFormatter = new Intl.NumberFormat();
|
||||||
|
|
||||||
private sortCrawls(crawls: CrawlSearchResult[]): CrawlSearchResult[] {
|
private filterCrawls = (crawls: Crawl[]) =>
|
||||||
return orderBy(({ item }) => item[this.orderBy.field])(
|
this.filterByState.length
|
||||||
this.orderBy.direction
|
? crawls.filter((crawl) =>
|
||||||
)(crawls) as CrawlSearchResult[];
|
this.filterByState.some((state) => crawl.state === state)
|
||||||
}
|
)
|
||||||
|
: crawls;
|
||||||
|
|
||||||
|
private sortCrawls = (
|
||||||
|
crawlsResults: CrawlSearchResult[]
|
||||||
|
): CrawlSearchResult[] =>
|
||||||
|
orderBy(({ item }) => item[this.orderBy.field])(this.orderBy.direction)(
|
||||||
|
crawlsResults
|
||||||
|
) as CrawlSearchResult[];
|
||||||
|
|
||||||
protected willUpdate(changedProperties: Map<string, any>) {
|
protected willUpdate(changedProperties: Map<string, any>) {
|
||||||
if (
|
if (
|
||||||
@ -143,7 +217,9 @@ export class CrawlsList extends LiteElement {
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<main>
|
<main>
|
||||||
<header class="sticky z-10 mb-3 top-0 py-2 bg-neutral-0">
|
<header
|
||||||
|
class="sticky z-10 mb-3 top-2 p-2 bg-neutral-50 border rounded-lg"
|
||||||
|
>
|
||||||
${this.renderControls()}
|
${this.renderControls()}
|
||||||
</header>
|
</header>
|
||||||
<section>
|
<section>
|
||||||
@ -179,103 +255,162 @@ export class CrawlsList extends LiteElement {
|
|||||||
|
|
||||||
private renderControls() {
|
private renderControls() {
|
||||||
return html`
|
return html`
|
||||||
<div class="grid grid-cols-2 gap-3 items-center">
|
<div
|
||||||
<div class="col-span-2 md:col-span-1">
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-[minmax(0,100%)_fit-content(100%)_fit-content(100%)] gap-x-2 gap-y-2 items-center"
|
||||||
|
>
|
||||||
|
<div class="col-span-1 md:col-span-2 lg:col-span-1">
|
||||||
<sl-input
|
<sl-input
|
||||||
class="w-full"
|
class="w-full"
|
||||||
slot="trigger"
|
slot="trigger"
|
||||||
placeholder=${msg("Search by Crawl Config name or ID")}
|
placeholder=${msg("Search by Crawl Config name or ID")}
|
||||||
clearable
|
clearable
|
||||||
?disabled=${!this.crawls?.length}
|
?disabled=${!this.crawls?.length}
|
||||||
|
value=${this.searchBy}
|
||||||
|
@sl-clear=${() => {
|
||||||
|
this.onSearchInput.cancel();
|
||||||
|
this.searchBy = "";
|
||||||
|
}}
|
||||||
@sl-input=${this.onSearchInput}
|
@sl-input=${this.onSearchInput}
|
||||||
>
|
>
|
||||||
<sl-icon name="search" slot="prefix"></sl-icon>
|
<sl-icon name="search" slot="prefix"></sl-icon>
|
||||||
</sl-input>
|
</sl-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-12 md:col-span-1 flex items-center justify-end">
|
<div class="flex items-center">
|
||||||
${this.userId
|
<div class="text-neutral-500 mx-2">${msg("View:")}</div>
|
||||||
? html`<label class="mr-3">
|
<sl-select
|
||||||
<span class="text-neutral-500 mr-1"
|
class="flex-1 md:min-w-[14.5rem]"
|
||||||
>${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 class="whitespace-nowrap text-neutral-500 mr-2">
|
|
||||||
${msg("Sort By")}
|
|
||||||
</div>
|
|
||||||
<sl-dropdown
|
|
||||||
placement="bottom-end"
|
placement="bottom-end"
|
||||||
distance="4"
|
distance="4"
|
||||||
@sl-select=${(e: any) => {
|
size="small"
|
||||||
const [field, direction] = e.detail.item.value.split("_");
|
pill
|
||||||
this.orderBy = {
|
.value=${this.filterByState}
|
||||||
field: field,
|
multiple
|
||||||
direction: direction,
|
max-tags-visible="1"
|
||||||
};
|
placeholder=${msg("All Crawls")}
|
||||||
|
@sl-change=${(e: CustomEvent) => {
|
||||||
|
const value = (e.target as SlSelect).value as CrawlState[];
|
||||||
|
this.filterByState = value;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<sl-button
|
${activeCrawlStates.map(
|
||||||
slot="trigger"
|
(state) => html`
|
||||||
|
<sl-menu-item value=${state}>
|
||||||
|
${crawlState[state].label}</sl-menu-item
|
||||||
|
>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
<sl-divider></sl-divider>
|
||||||
|
${inactiveCrawlStates.map(
|
||||||
|
(state) => html`
|
||||||
|
<sl-menu-item value=${state}>
|
||||||
|
${crawlState[state].label}</sl-menu-item
|
||||||
|
>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
</sl-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="whitespace-nowrap text-neutral-500 mx-2">
|
||||||
|
${msg("Sort by:")}
|
||||||
|
</div>
|
||||||
|
<div class="grow flex">
|
||||||
|
<sl-select
|
||||||
|
class="flex-1 md:min-w-[9.2rem]"
|
||||||
|
placement="bottom-end"
|
||||||
|
distance="4"
|
||||||
size="small"
|
size="small"
|
||||||
pill
|
pill
|
||||||
caret
|
value=${this.orderBy.field}
|
||||||
?disabled=${!this.crawls?.length}
|
@sl-select=${(e: any) => {
|
||||||
>${(sortableFieldLabels as any)[this.orderBy.field] ||
|
const field = e.detail.item.value as SortField;
|
||||||
sortableFieldLabels[
|
this.orderBy = {
|
||||||
`${this.orderBy.field}_${this.orderBy.direction}`
|
field: field,
|
||||||
]}</sl-button
|
direction:
|
||||||
|
sortableFields[field].defaultDirection ||
|
||||||
|
this.orderBy.direction,
|
||||||
|
};
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<sl-menu>
|
${Object.entries(sortableFields).map(
|
||||||
${Object.entries(sortableFieldLabels).map(
|
([value, { label }]) => html`
|
||||||
([value, label]) => html`
|
<sl-menu-item value=${value}>${label}</sl-menu-item>
|
||||||
<sl-menu-item
|
|
||||||
value=${value}
|
|
||||||
?checked=${value ===
|
|
||||||
`${this.orderBy.field}_${this.orderBy.direction}`}
|
|
||||||
>${label}</sl-menu-item
|
|
||||||
>
|
|
||||||
`
|
`
|
||||||
)}
|
)}
|
||||||
</sl-menu>
|
</sl-select>
|
||||||
</sl-dropdown>
|
<sl-icon-button
|
||||||
<sl-icon-button
|
name="arrow-down-up"
|
||||||
name="arrow-down-up"
|
label=${msg("Reverse sort")}
|
||||||
label=${msg("Reverse sort")}
|
@click=${() => {
|
||||||
@click=${() => {
|
this.orderBy = {
|
||||||
this.orderBy = {
|
...this.orderBy,
|
||||||
...this.orderBy,
|
direction: this.orderBy.direction === "asc" ? "desc" : "asc",
|
||||||
direction: this.orderBy.direction === "asc" ? "desc" : "asc",
|
};
|
||||||
};
|
}}
|
||||||
}}
|
></sl-icon-button>
|
||||||
></sl-icon-button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${this.userId
|
||||||
|
? html` <div class="h-6 mt-2 flex justify-end">
|
||||||
|
<label>
|
||||||
|
<span class="text-neutral-500 text-xs mr-1"
|
||||||
|
>${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>`
|
||||||
|
: ""}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderCrawlList() {
|
private renderCrawlList() {
|
||||||
// Return search results if valid filter string is available,
|
// Return search results if valid filter string is available,
|
||||||
// otherwise format crawls list like search results
|
// otherwise format crawls list like search results
|
||||||
const filterResults =
|
const searchResults =
|
||||||
this.filterBy.length >= MIN_SEARCH_LENGTH
|
this.searchBy.length >= MIN_SEARCH_LENGTH
|
||||||
? () => this.fuse.search(this.filterBy)
|
? () => this.fuse.search(this.searchBy)
|
||||||
: map((crawl) => ({ item: crawl }));
|
: map((crawl) => ({ item: crawl }));
|
||||||
|
const filteredCrawls = flow(
|
||||||
|
this.filterCrawls,
|
||||||
|
searchResults
|
||||||
|
)(this.crawls as Crawl[]);
|
||||||
|
|
||||||
|
if (!filteredCrawls.length) {
|
||||||
|
return html`
|
||||||
|
<div class="border rounded-lg bg-neutral-50 p-4">
|
||||||
|
<p class="text-center">
|
||||||
|
<span class="text-neutral-400"
|
||||||
|
>${msg("No matching crawls found.")}</span
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="text-neutral-500 font-medium underline hover:no-underline"
|
||||||
|
@click=${() => {
|
||||||
|
this.filterByState = [];
|
||||||
|
this.onSearchInput.cancel();
|
||||||
|
this.searchBy = "";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${msg("Clear all filters")}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ul class="border rounded">
|
<ul class="border rounded">
|
||||||
${flow(
|
${flow(
|
||||||
filterResults,
|
this.sortCrawls,
|
||||||
this.sortCrawls.bind(this),
|
|
||||||
map(this.renderCrawlItem)
|
map(this.renderCrawlItem)
|
||||||
)(this.crawls as any)}
|
)(filteredCrawls as CrawlSearchResult[])}
|
||||||
</ul>
|
</ul>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -560,7 +695,7 @@ export class CrawlsList extends LiteElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private onSearchInput = debounce(200)((e: any) => {
|
private onSearchInput = debounce(200)((e: any) => {
|
||||||
this.filterBy = e.target.value;
|
this.searchBy = e.target.value;
|
||||||
}) as any;
|
}) as any;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
Loading…
Reference in New Issue
Block a user