CEC-4523: add bulk archive to /packages (#372)

* CEC-4523: add bulk archive to /packages
This commit is contained in:
Tristan Timblin
2023-06-26 12:35:17 -04:00
committed by GitHub
parent 787bb12260
commit 60c1f414a6
6 changed files with 328 additions and 67 deletions

View File

@@ -6284,7 +6284,44 @@ exports[`App Route /packages authenticated 1`] = `
</div> </div>
<div <div
class="MuiGrid-root makeStyles-textRightAlign-0 MuiGrid-item MuiGrid-grid-md-4" class="MuiGrid-root makeStyles-textRightAlign-0 MuiGrid-item MuiGrid-grid-md-4"
>
<div
aria-label="choose action"
class="MuiButtonGroup-root MuiButtonGroup-contained css-zqcytf-MuiButtonGroup-root"
role="group"
>
<button
class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium Mui-disabled MuiButtonGroup-grouped MuiButtonGroup-groupedHorizontal MuiButtonGroup-groupedContained MuiButtonGroup-groupedContainedHorizontal MuiButtonGroup-groupedContainedPrimary MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButtonGroup-grouped MuiButtonGroup-groupedHorizontal MuiButtonGroup-groupedContained MuiButtonGroup-groupedContainedHorizontal MuiButtonGroup-groupedContainedPrimary css-sghohy-MuiButtonBase-root-MuiButton-root"
disabled=""
tabindex="-1"
type="button"
>
Archive
</button>
<button
aria-haspopup="menu"
aria-label="select action"
class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeSmall MuiButton-containedSizeSmall MuiButtonGroup-grouped MuiButtonGroup-groupedHorizontal MuiButtonGroup-groupedContained MuiButtonGroup-groupedContainedHorizontal MuiButtonGroup-groupedContainedPrimary MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeSmall MuiButton-containedSizeSmall MuiButtonGroup-grouped MuiButtonGroup-groupedHorizontal MuiButtonGroup-groupedContained MuiButtonGroup-groupedContainedHorizontal MuiButtonGroup-groupedContainedPrimary css-11qr2p8-MuiButtonBase-root-MuiButton-root"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium css-i4bv87-MuiSvgIcon-root"
data-testid="ArrowDropDownIcon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="m7 10 5 5 5-5z"
/> />
</svg>
<span
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
/>
</button>
</div>
</div>
</div> </div>
<table <table
class="MuiTable-root" class="MuiTable-root"
@@ -6295,6 +6332,40 @@ exports[`App Route /packages authenticated 1`] = `
<tr <tr
class="MuiTableRow-root MuiTableRow-head" class="MuiTableRow-root MuiTableRow-head"
> >
<th
class="MuiTableCell-root MuiTableCell-head MuiTableCell-paddingCheckbox"
scope="col"
>
<span
aria-disabled="false"
class="MuiButtonBase-root MuiIconButton-root PrivateSwitchBase-root-0 MuiCheckbox-root MuiCheckbox-colorSecondary MuiIconButton-colorSecondary"
>
<span
class="MuiIconButton-label"
>
<input
aria-label="select all desserts"
class="PrivateSwitchBase-input-0"
data-indeterminate="false"
type="checkbox"
value=""
/>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"
/>
</svg>
</span>
<span
class="MuiTouchRipple-root"
/>
</span>
</th>
<th <th
aria-sort="ascending" aria-sort="ascending"
class="MuiTableCell-root MuiTableCell-head MuiTableCell-alignCenter" class="MuiTableCell-root MuiTableCell-head MuiTableCell-alignCenter"
@@ -6476,6 +6547,38 @@ exports[`App Route /packages authenticated 1`] = `
<tr <tr
class="MuiTableRow-root" class="MuiTableRow-root"
> >
<td
class="MuiTableCell-root MuiTableCell-body MuiTableCell-paddingCheckbox"
>
<span
aria-disabled="false"
class="MuiButtonBase-root MuiIconButton-root PrivateSwitchBase-root-0 MuiCheckbox-root MuiCheckbox-colorSecondary MuiIconButton-colorSecondary"
>
<span
class="MuiIconButton-label"
>
<input
class="PrivateSwitchBase-input-0"
data-indeterminate="false"
type="checkbox"
value=""
/>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"
/>
</svg>
</span>
<span
class="MuiTouchRipple-root"
/>
</span>
</td>
<td <td
class="MuiTableCell-root MuiTableCell-body MuiTableCell-alignCenter" class="MuiTableCell-root MuiTableCell-body MuiTableCell-alignCenter"
> >

View File

@@ -6,7 +6,8 @@ import {
TableFooter, TableFooter,
TablePagination, TablePagination,
TableRow, TableRow,
Tooltip Tooltip,
Checkbox,
} from "@material-ui/core"; } from "@material-ui/core";
import DeleteIcon from "@material-ui/icons/Delete"; import DeleteIcon from "@material-ui/icons/Delete";
import SendIcon from "@material-ui/icons/Send"; import SendIcon from "@material-ui/icons/Send";
@@ -37,6 +38,8 @@ import DeleteConfirmation from "../../DeleteConfirmation";
import TableHeaderSortable from "../../Table/HeaderSortable"; import TableHeaderSortable from "../../Table/HeaderSortable";
import { useLocalStorage } from "../../useLocalStorage"; import { useLocalStorage } from "../../useLocalStorage";
import useStyles from "../../useStyles"; import useStyles from "../../useStyles";
import { useArchiveManifests } from "../../../hooks";
import DropDownButton from "../../Controls/DropDownButton";
const tableColumns = [ const tableColumns = [
{ {
@@ -92,12 +95,12 @@ const MainForm = () => {
const [order, setOrder] = useState("asc"); const [order, setOrder] = useState("asc");
const [search, setSearch] = useLocalStorage("DEPLOYMENT_SEARCH", ""); const [search, setSearch] = useLocalStorage("DEPLOYMENT_SEARCH", "");
const [active, setActive] = useLocalStorage("DEPLOYMENT_ACTIVE", "true"); const [active, setActive] = useLocalStorage("DEPLOYMENT_ACTIVE", "true");
const [selected, setSelected] = useState([]);
const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleteId, setDeleteId] = useState("");
const [deleteRowName, setDeleteRowName] = useState(""); const [deleteRowName, setDeleteRowName] = useState("");
const { getManifests, deleteManifest, manifests, totalManifests } = const { getManifests, manifests, totalManifests } =
useManifestsContext(); useManifestsContext();
const { setMessage, setTitle, setSitePath } = useStatusContext(); const { setMessage, setTitle, setSitePath } = useStatusContext();
const { const {
@@ -107,6 +110,7 @@ const MainForm = () => {
groups, groups,
providers, providers,
} = useUserContext(); } = useUserContext();
const { archive } = useArchiveManifests(token);
const sortHandler = (event, property) => { const sortHandler = (event, property) => {
if (property === orderBy) { if (property === orderBy) {
@@ -170,21 +174,45 @@ const MainForm = () => {
} }
} }
const setDeletePopup = (id, row) => { const handleSelectAll = () => {
setDeleteId(id); setSelected((selected) => selected.length ? [] : manifests);
setDeleteRowName(`${row.name} ${row.version}`); };
const handleSelect = (event, manifest) => {
setSelected((selected) => {
if (event.target.checked && selected.find(({ id }) => id === manifest.id)) {
return selected;
} else if (event.target.checked) {
return [...selected, manifest];
}
return selected.filter(({ id }) => id !== manifest.id);
});
};
const setDeletePopup = (row) => {
handleSelect({ target: { checked: true } }, row);
setShowDeleteModal(true); setShowDeleteModal(true);
}; };
const onDelete = async (manifest_id) => { const onDelete = async () => {
try { try {
await deleteManifest(parseInt(manifest_id), token); await archive(selected.map((manifest) => manifest.id))
.then(({ summary }) => {
setMessage(summary);
});
} catch (e) { } catch (e) {
setMessage(e.message); setMessage(e.message);
logger.warn(e.stack); logger.warn(e.stack);
} }
}; };
useEffect(() => {
setDeleteRowName(() => selected
.map((manifest) => `${manifest.name} ${manifest.version}`)
.join(", ")
);
}, [selected]);
const Actions = (row) => { const Actions = (row) => {
let actions = []; let actions = [];
if (hasRole(groups, Permissions.FiskerMagnaRead, providers)) { if (hasRole(groups, Permissions.FiskerMagnaRead, providers)) {
@@ -232,7 +260,7 @@ const MainForm = () => {
return ( return (
<span key={`delete-${action.id}-of-key`}> <span key={`delete-${action.id}-of-key`}>
<Tooltip key={`delete-${action.id}`} title={action.tip}> <Tooltip key={`delete-${action.id}`} title={action.tip}>
<Link to="#" onClick={() => setDeletePopup(action.id, row)}> <Link to="#" onClick={() => setDeletePopup(row)}>
{action.icon} {action.icon}
</Link> </Link>
</Tooltip> </Tooltip>
@@ -264,7 +292,17 @@ const MainForm = () => {
</ToggleButtonGroup> </ToggleButtonGroup>
</RoleWrap> </RoleWrap>
</Grid> </Grid>
<Grid item md={4} className={classes.textRightAlign}></Grid> <Grid item md={4} className={classes.textRightAlign}>
<DropDownButton
actions={[
{
name: "Archive",
trigger: () => setShowDeleteModal(true),
disabled: !selected.length,
}
]}
/>
</Grid>
</Grid> </Grid>
<Table> <Table>
<TableHeaderSortable <TableHeaderSortable
@@ -273,10 +311,24 @@ const MainForm = () => {
order={order} order={order}
columnData={tableColumns} columnData={tableColumns}
onSortRequest={sortHandler} onSortRequest={sortHandler}
multiSelect
onSelectAll={handleSelectAll}
selectCount={selected ? selected.length : 0}
rowCount={manifests ? manifests.length : 0}
/> />
<TableBody> <TableBody>
{manifests.map((row) => ( {manifests.map((row) => {
const isSelected = selected
? !!selected.find(({ id }) => id === row.id)
: false;
return (
<TableRow key={row.id}> <TableRow key={row.id}>
<TableCell padding="checkbox">
<Checkbox
checked={isSelected}
onChange={(event) => handleSelect(event, row)}
/>
</TableCell>
<TableCell align="center">{row.id}</TableCell> <TableCell align="center">{row.id}</TableCell>
<TableCell align="center"> <TableCell align="center">
{row.name} {row.name}
@@ -304,7 +356,8 @@ const MainForm = () => {
</TableCell> </TableCell>
<TableCell align="center">{Actions(row)}</TableCell> <TableCell align="center">{Actions(row)}</TableCell>
</TableRow> </TableRow>
))} );
})}
</TableBody> </TableBody>
<TableFooter> <TableFooter>
<TableRow> <TableRow>
@@ -328,7 +381,7 @@ const MainForm = () => {
message={deleteRowName} message={deleteRowName}
open={showDeleteModal} open={showDeleteModal}
close={() => setShowDeleteModal(false)} close={() => setShowDeleteModal(false)}
deleteFunction={() => onDelete(deleteId)} deleteFunction={() => onDelete()}
/> />
</div> </div>
); );

1
src/hooks/index.js Normal file
View File

@@ -0,0 +1 @@
export { useArchiveManifests } from "./useArchiveManifests";

View File

@@ -0,0 +1,32 @@
import manifestsAPI from "../services/manifestsAPI";
import TaskRunner from "../utils/taskRunner";
export const useArchiveManifests = (token) => {
const archive = async (ids) => {
return new Promise((resolve) => {
const taskRunner = new TaskRunner(5, ids.length);
let errorCount = 0;
const task = (id) => {
return async () => manifestsAPI.deleteManifest(id, token);
}
ids.forEach((id) => taskRunner.push(task(id))
.then((response) => {
if (response.error) {
errorCount += 1;
}
})
);
taskRunner.onComplete().then((responses) => resolve({
summary: `${ids.length - errorCount} out of ${ids.length} manifests were deleted.`,
responses,
}));
});
}
return {
archive,
};
};

View File

@@ -1,36 +1,67 @@
export default class TaskRunner { export default class TaskRunner {
constructor(concurrencyLimit = 1) { constructor(concurrencyLimit = 1, total) {
this.queue = []; this._queue = [];
this.running = 0; this._index = 0;
this.concurrencyLimit = concurrencyLimit; this._running = 0;
this._complete = 0;
this._concurrencyLimit = concurrencyLimit;
if (total) {
this._total = total;
this._responses = new Array(total);
}
this._onComplete = new Promise((resolve, reject) => {
this._onCompleteResolve = resolve;
this._onCompleteReject = reject;
});
} }
execute() { execute() {
if (this.running >= this.concurrencyLimit || this.queue.length === 0) { if (this._running >= this._concurrencyLimit || this._queue.length === 0) {
return; return;
} }
const task = this.queue.shift(); const task = this._queue.shift();
this.running += 1; this._running += 1;
task(); task(this._index);
this._index += 1;
} }
async push(fn) { async push(fn) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const task = async () => { const task = async (index) => {
try { try {
const result = await fn(); const response = await fn();
resolve(result); if (this._responses) {
this._responses[index] = response;
}
resolve(response);
} catch (error) { } catch (error) {
reject(error); reject(error);
} finally { } finally {
this.running -= 1; this._running -= 1;
this.#progress();
this.execute(); this.execute();
} }
} }
this.queue.push(task); this._queue.push(task);
this.execute(); this.execute();
}); });
} }
#progress() {
this._complete += 1;
if (this._complete === this._total) {
this._onCompleteResolve(this._responses);
}
}
async onComplete() {
if (!this._total) {
this._onCompleteReject(new Error("Total is required to determine onComplete."));
}
return this._onComplete;
}
} }

View File

@@ -4,6 +4,10 @@ const mockPromise = async (id, ms) => {
await new Promise(resolve => setTimeout(resolve, ms)); await new Promise(resolve => setTimeout(resolve, ms));
return id; return id;
} }
const mockPromiseError = async (id, ms) => {
await new Promise(resolve => setTimeout(resolve, ms));
return new Error(`Task ${id} had an error`);
}
const asyncFn1 = () => mockPromise(1, 200); const asyncFn1 = () => mockPromise(1, 200);
const asyncFn2 = () => mockPromise(2, 100); const asyncFn2 = () => mockPromise(2, 100);
@@ -12,19 +16,19 @@ const asyncFn3 = () => mockPromise(3, 50);
describe("TaskRunner", () => { describe("TaskRunner", () => {
it("runs task added to queue, when space available", () => { it("runs task added to queue, when space available", () => {
const taskRunner = new TaskRunner(2); const taskRunner = new TaskRunner(2);
expect(taskRunner.running).toEqual(0); expect(taskRunner._running).toEqual(0);
taskRunner.push(() => mockPromise(1, 300)); taskRunner.push(() => mockPromise(1, 300));
expect(taskRunner.running).toEqual(1); expect(taskRunner._running).toEqual(1);
}); });
it("keeps task in queue when at concurrency limit", () => { it("keeps task in queue when at concurrency limit", () => {
const taskRunner = new TaskRunner(2); const taskRunner = new TaskRunner(2);
expect(taskRunner.running).toEqual(0); expect(taskRunner._running).toEqual(0);
taskRunner.push(() => mockPromise(1, 100)); taskRunner.push(() => mockPromise(1, 100));
taskRunner.push(() => mockPromise(2, 25)); taskRunner.push(() => mockPromise(2, 25));
taskRunner.push(() => mockPromise(3, 10)); taskRunner.push(() => mockPromise(3, 10));
expect(taskRunner.running).toEqual(2); expect(taskRunner._running).toEqual(2);
expect(taskRunner.queue.length).toEqual(1); expect(taskRunner._queue.length).toEqual(1);
}); });
it("runs queued tasks as space becomes available", async () => { it("runs queued tasks as space becomes available", async () => {
@@ -32,9 +36,9 @@ describe("TaskRunner", () => {
taskRunner.push(() => mockPromise(1, 600)); taskRunner.push(() => mockPromise(1, 600));
taskRunner.push(() => mockPromise(2, 300)); taskRunner.push(() => mockPromise(2, 300));
taskRunner.push(() => mockPromise(3, 100)); taskRunner.push(() => mockPromise(3, 100));
expect(taskRunner.queue.length).toEqual(1); expect(taskRunner._queue.length).toEqual(1);
await new Promise(r => setTimeout(r, 301)); await new Promise(r => setTimeout(r, 301));
expect(taskRunner.queue.length).toEqual(0); expect(taskRunner._queue.length).toEqual(0);
}); });
it("runs tasks in order", async () => { it("runs tasks in order", async () => {
@@ -55,4 +59,41 @@ describe("TaskRunner", () => {
await new Promise(resolve => setTimeout(resolve, 500)); await new Promise(resolve => setTimeout(resolve, 500));
expect(actual).toEqual([2, 3, 1]); expect(actual).toEqual([2, 3, 1]);
}); });
})
it("resolves a promise when all tasks are complete", async () => {
const taskRunner = new TaskRunner(2, 5);
taskRunner.push(() => mockPromise(1, 600));
taskRunner.push(() => mockPromise(2, 300));
taskRunner.push(() => mockPromise(3, 200));
taskRunner.push(() => mockPromise(4, 600));
taskRunner.push(() => mockPromise(5, 100));
await taskRunner.onComplete().then((actual) => {
expect(actual).toStrictEqual([1, 2, 3, 4, 5]);
});
});
it("resolves a promise when all tasks are complete, even if some fail", async () => {
const error = new Error(`Task 3 had an error`);
const taskRunner = new TaskRunner(2, 5);
taskRunner.push(() => mockPromise(1, 600));
taskRunner.push(() => mockPromise(2, 300));
taskRunner.push(() => mockPromiseError(3, 200));
taskRunner.push(() => mockPromise(4, 600));
taskRunner.push(() => mockPromise(5, 100));
await taskRunner.onComplete().then((actual) => {
expect(actual).toStrictEqual([1, 2, error, 4, 5]);
});
});
it("rejects a promise when the total number of tasks is unknown", async () => {
const taskRunner = new TaskRunner(2);
taskRunner.push(() => mockPromise(1, 600));
taskRunner.push(() => mockPromise(2, 300));
taskRunner.push(() => mockPromise(3, 200));
taskRunner.push(() => mockPromise(4, 600));
taskRunner.push(() => mockPromise(5, 100));
await taskRunner.onComplete().catch((error) => {
expect(error.message).toBe("Total is required to determine onComplete.");
});
});
});