Skip to content

Commit

Permalink
lib: introduce an upload() helper for fsreplace1
Browse files Browse the repository at this point in the history
Introduces a helper function which handles uploading a ReadableStream to
a server with `fsreplace1`, sending data in chunks, with proper flow
control and progress reporting.  This is made to be used for
cockpit-files but is re-usable for other plugins.

Add a React component to the playground as a proof of concept, and add a
couple of integration tests based on it.

Co-authored-by: Allison Karlitskaya <allison.karlitskaya@redhat.com>
  • Loading branch information
jelly and allisonkarlitskaya committed Apr 16, 2024
1 parent 0853050 commit 7baa10f
Show file tree
Hide file tree
Showing 9 changed files with 398 additions and 3 deletions.
141 changes: 141 additions & 0 deletions pkg/lib/cockpit-upload-helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* This file is part of Cockpit.
*
* Copyright (C) 2024 Red Hat, Inc.
*
* Cockpit is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation; either version 2.1 of the License, or
* (at your option) any later version.
*
* Cockpit is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
*/

import cockpit from 'cockpit';

// These are the same values used by the bridge (in channel.py)
const BLOCK_SIZE = 16 << 10; // 16kiB
const FLOW_WINDOW = 2 << 20; // 2MiB

function debug(...args: unknown[]) {
if (window.debugging == 'all' || window.debugging?.includes('upload'))
console.debug('upload', ...args);
}

class UploadError extends Error {
name = 'UploadError';
}

class Waiter {
unblock = () => { };
block(): Promise<void> {
return new Promise(resolve => { this.unblock = resolve });
}
}

export async function upload(
destination: string,
stream: ReadableStream,
progress?: (bytes_sent: number) => void,
signal?: AbortSignal,
options?: cockpit.JsonObject
) {
let close_message = null as (cockpit.JsonObject | null);
let outstanding = 0; // for flow control
let delivered = 0; // for progress reporting

// This variable is the most important thing in this function. The main
// upload loop will do work for as long as it can, and then it .block()s on
// the waiter until something changes (ack, close, abort, etc). All of
// those things call .unblock() to resume the loop.
const event_waiter = new Waiter();

if (signal) {
signal.throwIfAborted(); // early exit
signal.addEventListener('abort', event_waiter.unblock);
}

const opts = {
payload: 'fsreplace1',
path: destination,
binary: true,
'send-acks': 'bytes',
...options,
} as const;
debug('requesting channel', opts);
const channel = cockpit.channel(opts);
channel.addEventListener('control', (_ev, message) => {
debug('control', message);
if (message.command === 'ack') {
cockpit.assert(typeof message.bytes === 'number', 'bytes not a number');
delivered += message.bytes;
if (progress) {
debug('progress', delivered);
progress(delivered);
}
outstanding -= message.bytes;
debug('outstanding -- to', outstanding);
event_waiter.unblock();
}
});
channel.addEventListener('close', (_ev, message) => {
debug('close', message);
close_message = message;
event_waiter.unblock();
});

try {
debug('starting file send', stream);
const reader = stream.getReader({ mode: 'byob' }); // byob to choose the block size
let eof = false;

// eslint-disable-next-line no-unmodified-loop-condition
while (!close_message) {
/* We do the following steps for as long as the channel is open:
* - if there is room to write more data, do that
* - otherwise, block on the waiter until something changes
* - in any case, check for cancellation, repeat
* The idea here is that each loop iteration will `await` one
* thing, and once it returns, we need to re-evaluate our state.
*/
if (!eof && outstanding < FLOW_WINDOW) {
const { done, value } = await reader.read(new Uint8Array(BLOCK_SIZE));
if (done) {
debug('sending done');
channel.control({ command: 'done' });
eof = true;
} else {
debug('sending', value.length, 'bytes');
channel.send(value);
outstanding += value.length;
debug('outstanding ++ to', outstanding);
}
if (signal) {
signal.throwIfAborted();
}
} else {
debug('sleeping', outstanding, 'of', FLOW_WINDOW, 'eof', eof);
await event_waiter.block();
}
if (signal) {
signal.throwIfAborted();
}
}

if (close_message.problem) {
throw new UploadError(cockpit.message(close_message));
} else {
cockpit.assert(typeof close_message.tag === 'string', "tag missing on close message");
return close_message.tag;
}
} finally {
debug('finally');
channel.close(); // maybe we got aborted
}
}
3 changes: 3 additions & 0 deletions pkg/lib/cockpit.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ declare module 'cockpit' {
id: string | null;
binary: boolean;
options: JsonObject;
ready: boolean;
valid: boolean;
send(data: T): void;
control(options: ControlMessage): void;
Expand Down Expand Up @@ -192,6 +193,8 @@ declare module 'cockpit' {

/* === String helpers ======================== */

function message(problem: string | JsonObject): string;

function gettext(message: string): string;
function gettext(context: string, message?: string): string;
function ngettext(message1: string, messageN: string, n: number): string;
Expand Down
3 changes: 3 additions & 0 deletions pkg/lib/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
interface Window {
debugging?: string;
}
141 changes: 141 additions & 0 deletions pkg/playground/react-demo-file-upload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* This file is part of Cockpit.
*
* Copyright (C) 2024 Red Hat, Inc.
*
* Cockpit is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation; either version 2.1 of the License, or
* (at your option) any later version.
*
* Cockpit is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
*/

import cockpit from "cockpit";
import React, { useRef, useState } from "react";
import { Container, createRoot } from 'react-dom/client';

import { Alert, AlertActionCloseButton } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
import { Progress } from "@patternfly/react-core/dist/esm/components/Progress/index.js";
import { TimesIcon, UploadIcon } from "@patternfly/react-icons";

import { FileAutoComplete } from "cockpit-components-file-autocomplete.jsx";
import { upload } from "cockpit-upload-helper";

const _ = cockpit.gettext;

export const UploadButton = () => {
const ref = useRef<HTMLInputElement>(null);
const [files, setFiles] = useState<{[name: string]: {file: File, progress: number, cancel:() => void}}>({});
const [alert, setAlert] = useState<{variant: "warning" | "danger", title: string, message: string} | null>(null);
const [dest, setDest] = useState("/home/admin/");
let next_progress = 0;

const handleClick = () => {
if (ref.current) {
ref.current.click();
}
};

const onUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
cockpit.assert(event.target.files, "not an <input type='file'>?");
setAlert(null);
await Promise.allSettled(Array.from(event.target.files).map(async (file: File) => {
const destination = `${dest}${file.name}`;
const abort = new AbortController();

setFiles(oldFiles => {
return {
[file.name]: { file, progress: 0, cancel: () => abort.abort() },
...oldFiles,
};
});

try {
await upload(destination, file.stream(), (progress) => {
const now = performance.now();
if (now < next_progress)
return;
next_progress = now + 200; // only rerender every 200ms
setFiles(oldFiles => {
const oldFile = oldFiles[file.name];
return {
...oldFiles,
[file.name]: { ...oldFile, progress },
};
});
}, abort.signal);
} catch (exc) {
cockpit.assert(exc instanceof Error, "Unknown exception type");
if (exc instanceof DOMException && exc.name == 'AbortError') {
setAlert({ variant: "warning", title: 'Aborted', message: '' });
} else {
setAlert({ variant: "danger", title: 'Upload Error', message: exc.message });
}
} finally {
setFiles(oldFiles => {
const copy = { ...oldFiles };
delete copy[file.name];
return copy;
});
}
}));

// Reset input field in the case a download was cancelled and has to be re-uploaded
// https://stackoverflow.com/questions/26634616/filereader-upload-same-file-again-not-working
event.target.value = "";
};

return (
<>
<Flex direction={{ default: "column" }}>
<FileAutoComplete className="upload-file-dest" value={dest} onChange={setDest} />
<Button
id="upload-file-btn"
variant="secondary"
icon={<UploadIcon />}
isDisabled={Object.keys(files).length !== 0}
isLoading={Object.keys(files).length !== 0}
onClick={handleClick}
>
{_("Upload")}
</Button>
<input
ref={ref} type="file"
hidden multiple onChange={onUpload}
/>
</Flex>
{alert !== null &&
<Alert variant={alert.variant}
title={alert.title}
timeout={3000}
actionClose={<AlertActionCloseButton onClose={() => setAlert(null)} />}
>
<p>{alert.message}</p>
</Alert>
}
{Object.keys(files).map((key, index) => {
const file = files[key];
return (
<React.Fragment key={index}>
<Progress className={`upload-progress-${index}`} key={file.file.name} value={file.progress} title={file.file.name} max={file.file.size} />
<Button className={`cancel-button-${index}`} icon={<TimesIcon />} onClick={file.cancel} />
</React.Fragment>
);
})}
</>
);
};

export const showUploadDemo = (rootElement: Container) => {
const root = createRoot(rootElement);
root.render(<UploadButton />);
};
5 changes: 5 additions & 0 deletions pkg/playground/react-patterns.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ <h3>Dialogs</h3>
<h3>Cards</h3>
<div id="demo-cards"></div>
</section>

<section class="pf-v5-c-page__main-section pf-m-light">
<h3>Upload</h3>
<div id="demo-upload"></div>
</section>
</main>
</div>
</body>
Expand Down
4 changes: 4 additions & 0 deletions pkg/playground/react-patterns.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { show_modal_dialog } from "cockpit-components-dialog.jsx";

import { PatternDialogBody } from "./react-demo-dialog.jsx";
import { showCardsDemo } from "./react-demo-cards.jsx";
import { showUploadDemo } from "./react-demo-file-upload.jsx";
import { showFileAcDemo, showFileAcDemoPreselected } from "./react-demo-file-autocomplete.jsx";

/* -----------------------------------------------------------------------------
Expand Down Expand Up @@ -126,4 +127,7 @@ document.addEventListener("DOMContentLoaded", function() {

// Cards
showCardsDemo(document.getElementById('demo-cards'));

// Upload
showUploadDemo(document.getElementById('demo-upload'));
});
9 changes: 7 additions & 2 deletions test/common/testlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,10 +301,15 @@ def switch_to_top(self):
"""
self.cdp.set_frame(None)

def upload_file(self, selector: str, file: str):
def upload_files(self, selector: str, files: 'list[str]') -> None:
"""Upload a local file to the browser
The selector should select the <input type="file"/> element.
Files is a list of absolute paths to files which should be uploaded.
"""
r = self.cdp.invoke("Runtime.evaluate", expression='document.querySelector(%s)' % jsquote(selector))
objectId = r["result"]["objectId"]
self.cdp.invoke("DOM.setFileInputFiles", files=[file], objectId=objectId)
self.cdp.invoke("DOM.setFileInputFiles", files=files, objectId=objectId)

def raise_cdp_exception(self, func, arg, details, trailer=None):
# unwrap a typical error string
Expand Down
Loading

0 comments on commit 7baa10f

Please sign in to comment.