-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
lib: introduce an upload() helper for fsreplace1
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
1 parent
0853050
commit 7baa10f
Showing
9 changed files
with
398 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
interface Window { | ||
debugging?: string; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 />); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.