github.com/treeverse/lakefs@v1.24.1-0.20240520134607-95648127bfb0/webui/src/pages/repositories/repository/objects.jsx (about) 1 import React, { useCallback, useEffect, useRef, useState } from "react"; 2 import dayjs from "dayjs"; 3 import { useOutletContext } from "react-router-dom"; 4 import {CheckboxIcon, UploadIcon, XIcon} from "@primer/octicons-react"; 5 import RefDropdown from "../../../lib/components/repository/refDropdown"; 6 import { 7 ActionGroup, 8 ActionsBar, 9 AlertError, 10 Loading, 11 PrefixSearchWidget, 12 RefreshButton, 13 Warnings 14 } from "../../../lib/components/controls"; 15 import Button from "react-bootstrap/Button"; 16 import Modal from "react-bootstrap/Modal"; 17 import Form from "react-bootstrap/Form"; 18 import Alert from "react-bootstrap/Alert"; 19 import { BsCloudArrowUp } from "react-icons/bs"; 20 21 import {humanSize, Tree} from "../../../lib/components/repository/tree"; 22 import {objects, staging, retention, repositories, imports, NotFoundError, uploadWithProgress} from "../../../lib/api"; 23 import {useAPI, useAPIWithPagination} from "../../../lib/hooks/api"; 24 import {useRefs} from "../../../lib/hooks/repo"; 25 import {useRouter} from "../../../lib/hooks/router"; 26 import {RefTypeBranch} from "../../../constants"; 27 import { 28 ExecuteImportButton, 29 ImportDone, 30 ImportForm, 31 ImportPhase, 32 ImportProgress, 33 startImport 34 } from "../services/import_data"; 35 import { Box } from "@mui/material"; 36 import { RepoError } from "./error"; 37 import { getContentType, getFileExtension, FileContents } from "./objectViewer"; 38 import {OverlayTrigger, ProgressBar} from "react-bootstrap"; 39 import Tooltip from "react-bootstrap/Tooltip"; 40 import { useSearchParams } from "react-router-dom"; 41 import { useStorageConfig } from "../../../lib/hooks/storageConfig"; 42 import {useDropzone} from "react-dropzone"; 43 import Container from "react-bootstrap/Container"; 44 import Row from "react-bootstrap/Row"; 45 import Col from "react-bootstrap/Col"; 46 import pMap from "p-map"; 47 48 const README_FILE_NAME = "README.md"; 49 const REPOSITORY_AGE_BEFORE_GC = 14; 50 const MAX_PARALLEL_UPLOADS = 5; 51 52 const ImportButton = ({ variant = "success", onClick, config }) => { 53 const tip = config.import_support 54 ? "Import data from a remote source" 55 : config.blockstore_type === "local" 56 ? "Import is not enabled for local blockstore" 57 : "Unsupported for " + config.blockstore_type + " blockstore"; 58 59 return ( 60 <OverlayTrigger placement="bottom" overlay={<Tooltip>{tip}</Tooltip>}> 61 <span> 62 <Button 63 variant={variant} 64 disabled={!config.import_support} 65 onClick={onClick} 66 > 67 <BsCloudArrowUp /> Import 68 </Button> 69 </span> 70 </OverlayTrigger> 71 ); 72 }; 73 74 export const useInterval = (callback, delay) => { 75 const savedCallback = useRef(); 76 77 useEffect(() => { 78 savedCallback.current = callback; 79 }, [callback]); 80 81 useEffect(() => { 82 function tick() { 83 savedCallback.current(); 84 } 85 if (delay !== null) { 86 const id = setInterval(tick, delay); 87 return () => clearInterval(id); 88 } 89 }, [delay]); 90 } 91 92 const ImportModal = ({config, repoId, referenceId, referenceType, path = '', onDone, onHide, show = false}) => { 93 const [importPhase, setImportPhase] = useState(ImportPhase.NotStarted); 94 const [numberOfImportedObjects, setNumberOfImportedObjects] = useState(0); 95 const [isImportEnabled, setIsImportEnabled] = useState(false); 96 const [importError, setImportError] = useState(null); 97 const [metadataFields, setMetadataFields] = useState([]) 98 const [importID, setImportID] = useState("") 99 100 const sourceRef = useRef(null); 101 const destRef = useRef(null); 102 const commitMsgRef = useRef(null); 103 104 useInterval(() => { 105 if (importID !== "" && importPhase === ImportPhase.InProgress) { 106 const getState = async () => { 107 try { 108 const importState = await imports.get(repoId, referenceId, importID); 109 setNumberOfImportedObjects(importState.ingested_objects); 110 if (importState.error) { 111 throw importState.error; 112 } 113 if (importState.completed) { 114 setImportPhase(ImportPhase.Completed); 115 onDone(); 116 } 117 } catch (error) { 118 setImportPhase(ImportPhase.Failed); 119 setImportError(error); 120 setIsImportEnabled(false); 121 } 122 }; 123 getState() 124 } 125 }, 3000); 126 127 if (!referenceId || referenceType !== RefTypeBranch) return <></> 128 129 let branchId = referenceId; 130 131 const resetState = () => { 132 setImportError(null); 133 setImportPhase(ImportPhase.NotStarted); 134 setIsImportEnabled(false); 135 setNumberOfImportedObjects(0); 136 setMetadataFields([]); 137 setImportID(""); 138 } 139 140 const hide = () => { 141 if ( 142 ImportPhase.InProgress === importPhase || 143 ImportPhase.Merging === importPhase 144 ) 145 return; 146 resetState(); 147 onHide(); 148 }; 149 150 const doImport = async () => { 151 setImportPhase(ImportPhase.InProgress); 152 try { 153 const metadata = {}; 154 metadataFields.forEach(pair => metadata[pair.key] = pair.value) 155 setImportPhase(ImportPhase.InProgress) 156 await startImport( 157 setImportID, 158 destRef.current.value, 159 commitMsgRef.current.value, 160 sourceRef.current.value, 161 repoId, 162 branchId, 163 metadata 164 ); 165 } catch (error) { 166 setImportPhase(ImportPhase.Failed); 167 setImportError(error); 168 setIsImportEnabled(false); 169 } 170 } 171 const pathStyle = {'minWidth': '25%'}; 172 173 return ( 174 <> 175 <Modal show={show} onHide={hide} size="lg"> 176 <Modal.Header closeButton> 177 <Modal.Title>Import data from {config.blockstore_type}</Modal.Title> 178 </Modal.Header> 179 <Modal.Body> 180 { 181 182 <ImportForm 183 config={config} 184 repo={repoId} 185 branch={branchId} 186 pathStyle={pathStyle} 187 sourceRef={sourceRef} 188 destRef={destRef} 189 updateSrcValidity={(isValid) => setIsImportEnabled(isValid)} 190 path={path} 191 commitMsgRef={commitMsgRef} 192 metadataFields={metadataFields} 193 setMetadataFields={setMetadataFields} 194 err={importError} 195 className={importPhase === ImportPhase.NotStarted || importPhase === ImportPhase.Failed ? '' : 'd-none'} 196 /> 197 } 198 { 199 importPhase === ImportPhase.InProgress && 200 <ImportProgress numObjects={numberOfImportedObjects}/> 201 } 202 { 203 importPhase === ImportPhase.Completed && 204 <ImportDone branch={branchId} 205 numObjects={numberOfImportedObjects}/> 206 } 207 </Modal.Body> 208 <Modal.Footer> 209 <Button variant="secondary" onClick={ async () => { 210 if (importPhase === ImportPhase.InProgress && importID.length > 0) { 211 await imports.delete(repoId, branchId, importID); 212 } 213 hide(); 214 }} hidden={importPhase === ImportPhase.Completed}> 215 Cancel 216 </Button> 217 218 <ExecuteImportButton 219 importPhase={importPhase} 220 importFunc={doImport} 221 doneFunc={hide} 222 isEnabled={isImportEnabled}/> 223 </Modal.Footer> 224 </Modal> 225 </> 226 ); 227 }; 228 229 function extractChecksumFromResponse(response) { 230 if (response.contentMD5) { 231 // convert base64 to hex 232 const raw = atob(response.contentMD5) 233 let result = ''; 234 for (let i = 0; i < raw.length; i++) { 235 const hex = raw.charCodeAt(i).toString(16); 236 result += (hex.length === 2 ? hex : '0' + hex); 237 } 238 return result; 239 } 240 241 if (response.etag) { 242 // drop any quote and space 243 return response.etag.replace(/[" ]+/g, ""); 244 } 245 return "" 246 } 247 248 const uploadFile = async (config, repo, reference, path, file, onProgress) => { 249 const fpath = destinationPath(path, file); 250 if (config.pre_sign_support_ui) { 251 let additionalHeaders; 252 if (config.blockstore_type === "azure") { 253 additionalHeaders = { "x-ms-blob-type": "BlockBlob" } 254 } 255 const getResp = await staging.get(repo.id, reference.id, fpath, config.pre_sign_support_ui); 256 const uploadResponse = await uploadWithProgress(getResp.presigned_url, file, 'PUT', onProgress, additionalHeaders) 257 if (uploadResponse.status >= 400) { 258 throw new Error(`Error uploading file: HTTP ${status}`) 259 } 260 const checksum = extractChecksumFromResponse(uploadResponse) 261 await staging.link(repo.id, reference.id, fpath, getResp, checksum, file.size, file.type); 262 } else { 263 await objects.upload(repo.id, reference.id, fpath, file, onProgress); 264 } 265 }; 266 267 const destinationPath = (path, file) => { 268 return `${path ? path : ""}${file.path.replace(/\\/g, '/').replace(/^\//, '')}`; 269 }; 270 271 const UploadCandidate = ({ repo, reference, path, file, state, onRemove = null }) => { 272 const fpath = destinationPath(path, file) 273 let uploadIndicator = null; 274 if (state && state.status === "uploading") { 275 uploadIndicator = <ProgressBar variant="success" now={state.percent}/> 276 } else if (state && state.status === "done") { 277 uploadIndicator = <strong><CheckboxIcon/></strong> 278 } else if (!state && onRemove !== null) { 279 uploadIndicator = ( 280 <a href="#" onClick={ e => { 281 e.preventDefault() 282 onRemove() 283 }}> 284 <XIcon /> 285 </a> 286 ); 287 } 288 return ( 289 <Container> 290 <Row className={`upload-item upload-item-${state ? state.status : "none"}`}> 291 <Col> 292 <span className="path"> 293 lakefs://{repo.id}/{reference.id}/{fpath} 294 </span> 295 </Col> 296 <Col xs md="2"> 297 <span className="size"> 298 {humanSize(file.size)} 299 </span> 300 </Col> 301 <Col xs md="1"> 302 <span className="upload-state"> 303 {uploadIndicator ? uploadIndicator : <></>} 304 </span> 305 </Col> 306 </Row> 307 </Container> 308 ) 309 }; 310 311 const UploadButton = ({config, repo, reference, path, onDone, onClick, onHide, show = false, disabled = false}) => { 312 const initialState = { 313 inProgress: false, 314 error: null, 315 done: false, 316 }; 317 const [currentPath, setCurrentPath] = useState(path); 318 const [uploadState, setUploadState] = useState(initialState); 319 const [files, setFiles] = useState([]); 320 const [fileStates, setFileStates] = useState({}); 321 const [abortController, setAbortController] = useState(null) 322 const onDrop = useCallback(acceptedFiles => { 323 setFiles([...acceptedFiles]) 324 }, [files]) 325 326 const { getRootProps, getInputProps, isDragAccept } = useDropzone({onDrop}) 327 328 if (!reference || reference.type !== RefTypeBranch) return <></>; 329 330 const hide = () => { 331 if (uploadState.inProgress) { 332 if (abortController !== null) { 333 abortController.abort() 334 } else { 335 return 336 } 337 } 338 setUploadState(initialState); 339 setFileStates({}); 340 setFiles([]); 341 setCurrentPath(path); 342 setAbortController(null) 343 onHide(); 344 }; 345 346 useEffect(() => { 347 setCurrentPath(path) 348 }, [path]) 349 350 const upload = async () => { 351 if (files.length < 1) { 352 return 353 } 354 355 const abortController = new AbortController() 356 setAbortController(abortController) 357 358 const mapper = async (file) => { 359 try { 360 setFileStates(next => ( {...next, [file.path]: {status: 'uploading', percent: 0}})) 361 await uploadFile(config, repo, reference, currentPath, file, progress => { 362 setFileStates(next => ( {...next, [file.path]: {status: 'uploading', percent: progress}})) 363 }) 364 } catch (error) { 365 setFileStates(next => ( {...next, [file.path]: {status: 'error'}})) 366 setUploadState({ ...initialState, error }); 367 throw error; 368 } 369 setFileStates(next => ( {...next, [file.path]: {status: 'done'}})) 370 } 371 372 setUploadState({...initialState, inProgress: true }); 373 try { 374 await pMap(files, mapper, { 375 concurrency: MAX_PARALLEL_UPLOADS, 376 signal: abortController.signal 377 }); 378 onDone(); 379 hide(); 380 } catch (error) { 381 if (error instanceof DOMException) { 382 // abort! 383 onDone(); 384 hide(); 385 } else { 386 setUploadState({ ...initialState, error }); 387 } 388 } 389 390 391 }; 392 393 const changeCurrentPath = useCallback(e => { 394 setCurrentPath(e.target.value) 395 }, [setCurrentPath]) 396 397 const onRemoveCandidate = useCallback(file => { 398 return () => setFiles(current => current.filter(f => f !== file)) 399 }, [setFiles]) 400 401 return ( 402 <> 403 <Modal size="xl" show={show} onHide={hide}> 404 <Modal.Header closeButton> 405 <Modal.Title>Upload Object</Modal.Title> 406 </Modal.Header> 407 <Modal.Body> 408 <Form 409 onSubmit={(e) => { 410 if (uploadState.inProgress) return; 411 e.preventDefault(); 412 upload(); 413 }} 414 > 415 {config?.warnings && ( 416 <Form.Group controlId="warnings" className="mb-3"> 417 <Warnings warnings={config.warnings} /> 418 </Form.Group> 419 )} 420 421 <Form.Group controlId="path" className="mb-3"> 422 <Form.Text>Path</Form.Text> 423 <Form.Control disabled={uploadState.inProgress} defaultValue={currentPath} onChange={changeCurrentPath}/> 424 </Form.Group> 425 426 <Form.Group controlId="content" className="mb-3"> 427 <div {...getRootProps({className: 'dropzone'})}> 428 <input {...getInputProps()} /> 429 <div className={isDragAccept ? "file-drop-zone file-drop-zone-focus" : "file-drop-zone"}> 430 Drag 'n' drop files or folders here (or click to select) 431 </div> 432 </div> 433 <aside className="mt-3"> 434 {(files && files.length > 0) && 435 <h5>{files.length} File{files.length > 1 ? "s":""} to upload ({humanSize(files.reduce((a,f) => a + f.size ,0))})</h5> 436 } 437 {files && files.map(file => 438 <UploadCandidate 439 key={file.path} 440 config={config} 441 repo={repo} 442 reference={reference} 443 file={file} 444 path={currentPath} 445 state={fileStates[file.path]} 446 onRemove={!uploadState.inProgress ? onRemoveCandidate(file) : null} 447 /> 448 )} 449 </aside> 450 </Form.Group> 451 </Form> 452 {(uploadState.error) ? (<AlertError error={uploadState.error}/>) : (<></>)} 453 </Modal.Body> 454 <Modal.Footer> 455 <Button variant="secondary" onClick={hide}> 456 Cancel 457 </Button> 458 <Button variant="success" disabled={uploadState.inProgress || files.length < 1} onClick={() => { 459 if (uploadState.inProgress) return; 460 upload() 461 }}> 462 {(uploadState.inProgress) ? 'Uploading...' : 'Upload'} 463 </Button> 464 </Modal.Footer> 465 </Modal> 466 467 <Button 468 variant={!config.import_support ? "success" : "light"} 469 disabled={disabled} 470 onClick={onClick} 471 > 472 <UploadIcon /> Upload Object 473 </Button> 474 </> 475 ); 476 }; 477 478 const TreeContainer = ({ 479 config, 480 repo, 481 reference, 482 path, 483 after, 484 onPaginate, 485 onRefresh, 486 onUpload, 487 onImport, 488 refreshToken, 489 }) => { 490 const { results, error, loading, nextPage } = useAPIWithPagination(() => { 491 return objects.list( 492 repo.id, 493 reference.id, 494 path, 495 after, 496 config.pre_sign_support_ui 497 ); 498 }, [repo.id, reference.id, path, after, refreshToken]); 499 const initialState = { 500 inProgress: false, 501 error: null, 502 done: false, 503 }; 504 const [deleteState, setDeleteState] = useState(initialState); 505 506 if (loading) return <Loading/>; 507 if (error) return <AlertError error={error}/>; 508 509 return ( 510 <> 511 {deleteState.error && <AlertError error={deleteState.error} onDismiss={() => setDeleteState(initialState)}/>} 512 <Tree 513 config={{config}} 514 repo={repo} 515 reference={reference} 516 path={(path) ? path : ""} 517 showActions={true} 518 results={results} 519 after={after} 520 nextPage={nextPage} 521 onPaginate={onPaginate} 522 onUpload={onUpload} 523 onImport={onImport} 524 onDelete={entry => { 525 objects 526 .delete(repo.id, reference.id, entry.path) 527 .catch(error => { 528 setDeleteState({...initialState, error: error}) 529 throw error 530 }) 531 .then(onRefresh) 532 }} 533 /></> 534 ); 535 } 536 537 const ReadmeContainer = ({ 538 config, 539 repo, 540 reference, 541 path = "", 542 refreshDep = "", 543 }) => { 544 let readmePath = ""; 545 546 if (path) { 547 readmePath = path.endsWith("/") 548 ? `${path}${README_FILE_NAME}` 549 : `${path}/${README_FILE_NAME}`; 550 } else { 551 readmePath = README_FILE_NAME; 552 } 553 const { response, error, loading } = useAPI( 554 () => objects.head(repo.id, reference.id, readmePath), 555 [path, refreshDep] 556 ); 557 558 if (loading || error) { 559 return <></>; // no file found. 560 } 561 562 const fileExtension = getFileExtension(readmePath); 563 const contentType = getContentType(response?.headers); 564 565 return ( 566 <FileContents 567 repoId={repo.id} 568 reference={reference} 569 path={readmePath} 570 fileExtension={fileExtension} 571 contentType={contentType} 572 error={error} 573 loading={loading} 574 showFullNavigator={false} 575 presign={config.pre_sign_support_ui} 576 /> 577 ); 578 } 579 580 const NoGCRulesWarning = ({ repoId }) => { 581 const storageKey = `show_gc_warning_${repoId}`; 582 const [show, setShow] = useState( 583 window.localStorage.getItem(storageKey) !== "false" 584 ); 585 const closeAndRemember = useCallback(() => { 586 window.localStorage.setItem(storageKey, "false"); 587 setShow(false); 588 }, [repoId]); 589 590 const { response } = useAPI(async () => { 591 const repo = await repositories.get(repoId); 592 if ( 593 !repo.storage_namespace.startsWith("s3:") && 594 !repo.storage_namespace.startsWith("http") 595 ) { 596 return false; 597 } 598 const createdAgo = dayjs().diff(dayjs.unix(repo.creation_date), "days"); 599 if (createdAgo > REPOSITORY_AGE_BEFORE_GC) { 600 try { 601 await retention.getGCPolicy(repoId); 602 } catch (e) { 603 if (e instanceof NotFoundError) { 604 return true; 605 } 606 } 607 } 608 return false; 609 }, [repoId]); 610 611 if (show && response) { 612 return ( 613 <Alert variant="warning" onClose={closeAndRemember} dismissible> 614 <strong>Warning</strong>: No garbage collection rules configured for 615 this repository.{" "} 616 <a 617 href="https://docs.lakefs.io/howto/garbage-collection/" 618 target="_blank" 619 rel="noreferrer" 620 > 621 Learn More 622 </a> 623 . 624 </Alert> 625 ); 626 } 627 return <></>; 628 }; 629 630 const ObjectsBrowser = ({ config, configError }) => { 631 const router = useRouter(); 632 const { path, after, importDialog } = router.query; 633 const [searchParams, setSearchParams] = useSearchParams(); 634 const { repo, reference, loading, error } = useRefs(); 635 const [showUpload, setShowUpload] = useState(false); 636 const [showImport, setShowImport] = useState(false); 637 const [refreshToken, setRefreshToken] = useState(false); 638 639 const refresh = () => setRefreshToken(!refreshToken); 640 const parts = (path && path.split("/")) || []; 641 const searchSuffix = parts.pop(); 642 let searchPrefix = parts.join("/"); 643 searchPrefix = searchPrefix && searchPrefix + "/"; 644 645 useEffect(() => { 646 if (importDialog) { 647 setShowImport(true); 648 searchParams.delete("importDialog"); 649 setSearchParams(searchParams); 650 } 651 }, [router.route, importDialog, searchParams, setSearchParams]); 652 653 if (loading || !config) return <Loading />; 654 if (error || configError) return <RepoError error={error || configError} />; 655 656 return ( 657 <> 658 <ActionsBar> 659 <ActionGroup orientation="left"> 660 <RefDropdown 661 emptyText={"Select Branch"} 662 repo={repo} 663 selected={reference} 664 withCommits={true} 665 withWorkspace={true} 666 selectRef={(ref) => 667 router.push({ 668 pathname: `/repositories/:repoId/objects`, 669 params: { 670 repoId: repo.id, 671 path: path === undefined ? "" : path, 672 }, 673 query: { ref: ref.id, path: path === undefined ? "" : path }, 674 }) 675 } 676 /> 677 </ActionGroup> 678 679 <ActionGroup orientation="right"> 680 <PrefixSearchWidget 681 text="Search by Prefix" 682 key={path} 683 defaultValue={searchSuffix} 684 onFilter={(prefix) => { 685 const query = { path: "" }; 686 if (searchPrefix !== undefined) query.path = searchPrefix; 687 if (prefix) query.path += prefix; 688 if (reference) query.ref = reference.id; 689 const url = { 690 pathname: `/repositories/:repoId/objects`, 691 query, 692 params: { repoId: repo.id }, 693 }; 694 router.push(url); 695 }} 696 /> 697 <RefreshButton onClick={refresh} /> 698 <UploadButton 699 config={config} 700 path={path} 701 repo={repo} 702 reference={reference} 703 onDone={refresh} 704 onClick={() => { 705 setShowUpload(true); 706 }} 707 onHide={() => { 708 setShowUpload(false); 709 }} 710 show={showUpload} 711 disabled={repo?.read_only} 712 /> 713 <ImportButton onClick={() => setShowImport(true)} config={config} /> 714 <ImportModal 715 config={config} 716 path={path} 717 repoId={repo.id} 718 referenceId={reference.id} 719 referenceType={reference.type} 720 onDone={refresh} 721 onHide={() => { 722 setShowImport(false); 723 }} 724 show={showImport} 725 /> 726 </ActionGroup> 727 </ActionsBar> 728 729 <NoGCRulesWarning repoId={repo.id} /> 730 731 <Box 732 sx={{ 733 display: "flex", 734 flexDirection: "column", 735 gap: "10px", 736 mb: "30px", 737 }} 738 > 739 <TreeContainer 740 config={config} 741 reference={reference} 742 repo={repo} 743 path={path ? path : ""} 744 after={after ? after : ""} 745 onPaginate={(after) => { 746 const query = { after }; 747 if (path) query.path = path; 748 if (reference) query.ref = reference.id; 749 const url = { 750 pathname: `/repositories/:repoId/objects`, 751 query, 752 params: { repoId: repo.id }, 753 }; 754 router.push(url); 755 }} 756 refreshToken={refreshToken} 757 onUpload={() => { 758 setShowUpload(true); 759 }} 760 onImport={() => { 761 setShowImport(true); 762 }} 763 onRefresh={refresh} 764 /> 765 766 <ReadmeContainer 767 config={config} 768 reference={reference} 769 repo={repo} 770 path={path} 771 refreshDep={refreshToken} 772 /> 773 </Box> 774 </> 775 ); 776 }; 777 778 const RepositoryObjectsPage = () => { 779 const config = useStorageConfig(); 780 const [setActivePage] = useOutletContext(); 781 useEffect(() => setActivePage("objects"), [setActivePage]); 782 783 return <ObjectsBrowser config={config} configError={config.error} />; 784 }; 785 786 export default RepositoryObjectsPage;