github.com/treeverse/lakefs@v1.24.1-0.20240520134607-95648127bfb0/webui/src/lib/components/repository/tree.jsx (about) 1 import React, { useCallback, useState } from "react"; 2 3 import dayjs from "dayjs"; 4 import { 5 PasteIcon, 6 DotIcon, 7 DownloadIcon, 8 FileDirectoryIcon, 9 FileIcon, 10 GearIcon, 11 InfoIcon, 12 LinkIcon, 13 PencilIcon, 14 PlusIcon, 15 TrashIcon, 16 LogIcon, 17 BeakerIcon, 18 } from "@primer/octicons-react"; 19 import Tooltip from "react-bootstrap/Tooltip"; 20 import Table from "react-bootstrap/Table"; 21 import Card from "react-bootstrap/Card"; 22 import OverlayTrigger from "react-bootstrap/OverlayTrigger"; 23 import Button from "react-bootstrap/Button"; 24 import Container from "react-bootstrap/Container"; 25 import Row from "react-bootstrap/Row"; 26 import Col from "react-bootstrap/Col"; 27 import Dropdown from "react-bootstrap/Dropdown"; 28 29 import { commits, linkToPath, objects } from "../../api"; 30 import { ConfirmationModal } from "../modals"; 31 import { Paginator } from "../pagination"; 32 import { Link } from "../nav"; 33 import { RefTypeBranch, RefTypeCommit } from "../../../constants"; 34 import {ClipboardButton, copyTextToClipboard, AlertError, Loading} from "../controls"; 35 import Modal from "react-bootstrap/Modal"; 36 import { useAPI } from "../../hooks/api"; 37 import noop from "lodash/noop"; 38 import {FaDownload} from "react-icons/fa"; 39 import {CommitInfoCard} from "./commits"; 40 41 export const humanSize = (bytes) => { 42 if (!bytes) return "0.0 B"; 43 const e = Math.floor(Math.log(bytes) / Math.log(1024)); 44 return ( 45 (bytes / Math.pow(1024, e)).toFixed(1) + " " + " KMGTP".charAt(e) + "B" 46 ); 47 }; 48 49 const Na = () => <span>—</span>; 50 51 const EntryRowActions = ({ repo, reference, entry, onDelete, presign, presign_ui = false }) => { 52 const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); 53 const handleCloseDeleteConfirmation = () => setShowDeleteConfirmation(false); 54 const handleShowDeleteConfirmation = () => setShowDeleteConfirmation(true); 55 const deleteConfirmMsg = `are you sure you wish to delete object "${entry.path}"?`; 56 const onSubmitDeletion = () => { 57 onDelete(entry); 58 setShowDeleteConfirmation(false); 59 }; 60 61 const [showObjectStat, setShowObjectStat] = useState(false); 62 const [showObjectOrigin, setShowObjectOrigin] = useState(false); 63 const [showPrefixSize, setShowPrefixSize] = useState(false); 64 65 const handleShowObjectOrigin = useCallback( 66 (e) => { 67 e.preventDefault(); 68 setShowObjectOrigin(true); 69 }, 70 [setShowObjectOrigin] 71 ); 72 73 const handleShowPrefixSize = useCallback(e => { 74 e.preventDefault(); 75 setShowPrefixSize(true) 76 }, [setShowPrefixSize]); 77 78 return ( 79 <> 80 <Dropdown align="end"> 81 <Dropdown.Toggle variant="light" size="sm" className={"row-hover"}> 82 <GearIcon /> 83 </Dropdown.Toggle> 84 85 <Dropdown.Menu> 86 {entry.path_type === "object" && presign && ( 87 <Dropdown.Item 88 onClick={async e => { 89 try { 90 const resp = await objects.getStat(repo.id, reference.id, entry.path, true); 91 copyTextToClipboard(resp.physical_address); 92 } catch (err) { 93 alert(err); 94 } 95 e.preventDefault(); 96 }} 97 > 98 <LinkIcon /> Copy Presigned URL 99 </Dropdown.Item> 100 )} 101 {entry.path_type === "object" && ( 102 <PathLink 103 path={entry.path} 104 reference={reference} 105 repoId={repo.id} 106 as={Dropdown.Item} 107 presign={presign_ui} 108 > 109 <DownloadIcon /> Download 110 </PathLink> 111 )} 112 {entry.path_type === "object" && ( 113 <Dropdown.Item 114 onClick={(e) => { 115 e.preventDefault(); 116 setShowObjectStat(true); 117 }} 118 > 119 <InfoIcon /> Object Info 120 </Dropdown.Item> 121 )} 122 123 <Dropdown.Item onClick={handleShowObjectOrigin}> 124 <LogIcon /> Blame 125 </Dropdown.Item> 126 127 <Dropdown.Item 128 onClick={(e) => { 129 copyTextToClipboard( 130 `lakefs://${repo.id}/${reference.id}/${entry.path}` 131 ); 132 e.preventDefault(); 133 }} 134 > 135 <PasteIcon /> Copy URI 136 </Dropdown.Item> 137 138 {entry.path_type === "object" && reference.type === RefTypeBranch && ( 139 <> 140 <Dropdown.Divider /> 141 <Dropdown.Item 142 onClick={(e) => { 143 e.preventDefault(); 144 handleShowDeleteConfirmation(); 145 }} 146 > 147 <TrashIcon /> Delete 148 </Dropdown.Item> 149 </> 150 )} 151 152 {entry.path_type === "common_prefix" && ( 153 <Dropdown.Item onClick={handleShowPrefixSize}> 154 <BeakerIcon /> Calculate Size 155 </Dropdown.Item> 156 )} 157 158 </Dropdown.Menu> 159 </Dropdown> 160 161 <ConfirmationModal 162 show={showDeleteConfirmation} 163 onHide={handleCloseDeleteConfirmation} 164 msg={deleteConfirmMsg} 165 onConfirm={onSubmitDeletion} 166 /> 167 168 <StatModal 169 entry={entry} 170 show={showObjectStat} 171 onHide={() => setShowObjectStat(false)} 172 /> 173 174 <OriginModal 175 entry={entry} 176 repo={repo} 177 reference={reference} 178 show={showObjectOrigin} 179 onHide={() => setShowObjectOrigin(false)} 180 /> 181 182 <PrefixSizeModal 183 entry={entry} 184 repo={repo} 185 reference={reference} 186 show={showPrefixSize} 187 onHide={() => setShowPrefixSize(false)} 188 /> 189 </> 190 ); 191 }; 192 193 const StatModal = ({ show, onHide, entry }) => { 194 return ( 195 <Modal show={show} onHide={onHide} size={"xl"}> 196 <Modal.Header closeButton> 197 <Modal.Title>Object Information</Modal.Title> 198 </Modal.Header> 199 <Modal.Body> 200 <Table responsive hover> 201 <tbody> 202 <tr> 203 <td> 204 <strong>Path</strong> 205 </td> 206 <td> 207 <code>{entry.path}</code> 208 </td> 209 </tr> 210 <tr> 211 <td> 212 <strong>Physical Address</strong> 213 </td> 214 <td> 215 <code>{entry.physical_address}</code> 216 </td> 217 </tr> 218 <tr> 219 <td> 220 <strong>Size (Bytes)</strong> 221 </td> 222 <td>{`${entry.size_bytes} (${humanSize(entry.size_bytes)})`}</td> 223 </tr> 224 <tr> 225 <td> 226 <strong>Checksum</strong> 227 </td> 228 <td> 229 <code>{entry.checksum}</code> 230 </td> 231 </tr> 232 <tr> 233 <td> 234 <strong>Last Modified</strong> 235 </td> 236 <td>{`${dayjs.unix(entry.mtime).fromNow()} (${dayjs 237 .unix(entry.mtime) 238 .format("MM/DD/YYYY HH:mm:ss")})`}</td> 239 </tr> 240 {entry.content_type && ( 241 <tr> 242 <td> 243 <strong>Content-Type</strong> 244 </td> 245 <td> 246 <code>{entry.content_type}</code> 247 </td> 248 </tr> 249 )} 250 {entry.metadata && ( 251 <tr> 252 <td> 253 <strong>Metadata</strong> 254 </td> 255 <td> 256 <EntryMetadata metadata={entry.metadata}/> 257 </td> 258 </tr> 259 )} 260 </tbody> 261 </Table> 262 </Modal.Body> 263 </Modal> 264 ); 265 }; 266 267 const EntryMetadata = ({ metadata }) => { 268 return ( 269 <Table hover striped> 270 <thead> 271 <tr> 272 <th>Key</th> 273 <th>Value</th> 274 </tr> 275 </thead> 276 <tbody> 277 {Object.getOwnPropertyNames(metadata).map(key => 278 <tr key={`metadata:${key}`}> 279 <td><code>{key}</code></td> 280 <td><code>{metadata[key]}</code></td> 281 </tr> 282 )} 283 </tbody> 284 </Table> 285 ) 286 }; 287 288 export const PrefixSizeInfoCard = ({ entry, totalObjects }) => { 289 const totalBytes = totalObjects.reduce((acc, obj) => acc + obj.size_bytes, 0) 290 const table = ( 291 <Table> 292 <Table bordered hover> 293 <tbody> 294 <tr> 295 <td><strong>path</strong></td> 296 <td><code>{entry.path}</code></td> 297 </tr> 298 <tr> 299 <td><strong>Total Objects</strong></td> 300 <td><code>{totalObjects.length.toLocaleString()}</code></td> 301 </tr> 302 <tr> 303 <td><strong>Total Size</strong></td> 304 <td><code>{totalBytes.toLocaleString()} Bytes ({humanSize(totalBytes)})</code></td> 305 </tr> 306 </tbody> 307 </Table> 308 </Table> 309 ) 310 return table; 311 } 312 313 const PrefixSizeModal = ({show, onHide, entry, repo, reference }) => { 314 const [progress, setProgress] = useState(0) 315 const { 316 response, 317 error, 318 loading, 319 } = useAPI(async () => { 320 if (show) { 321 setProgress(0) 322 let accumulator = [] 323 let finished = false 324 const iterator = objects.listAll(repo.id, reference.id, entry.path) 325 while (!finished) { 326 let {page, done} = await iterator.next() 327 accumulator = accumulator.concat(page) 328 setProgress(accumulator.length) 329 if (done) finished = true 330 } 331 return accumulator; 332 } 333 return null; 334 }, [show, repo.id, reference.id, entry.path, setProgress]); 335 336 let content = <Loading message={`Finding all objects (${progress.toLocaleString()} so far). This could take a while...`} />; 337 338 if (error) { 339 content = <AlertError error={error} />; 340 } 341 if (!loading && !error && response) { 342 content = ( 343 <PrefixSizeInfoCard repo={repo} reference={reference} entry={entry} totalObjects={response}/> 344 ); 345 } 346 347 if (!loading && !error && !response) { 348 content = ( 349 <> 350 <h5> 351 <small> 352 No objects found 353 </small> 354 </h5> 355 </> 356 ); 357 } 358 359 return ( 360 <Modal show={show} onHide={onHide} size={"lg"}> 361 <Modal.Header closeButton> 362 <Modal.Title> 363 Total Objects in Path 364 </Modal.Title> 365 </Modal.Header> 366 <Modal.Body>{content}</Modal.Body> 367 </Modal> 368 ); 369 }; 370 371 const OriginModal = ({ show, onHide, entry, repo, reference }) => { 372 const { 373 response: commit, 374 error, 375 loading, 376 } = useAPI(async () => { 377 if (show) { 378 return await commits.blame( 379 repo.id, 380 reference.id, 381 entry.path, 382 entry.path_type 383 ); 384 } 385 return null; 386 }, [show, repo.id, reference.id, entry.path]); 387 388 const pathType = entry.path_type === "object" ? "object" : "prefix"; 389 390 let content = <Loading />; 391 392 if (error) { 393 content = <AlertError error={error} />; 394 } 395 if (!loading && !error && commit) { 396 content = ( 397 <CommitInfoCard bare={true} repo={repo} commit={commit}/> 398 ); 399 } 400 401 if (!loading && !error && !commit) { 402 content = ( 403 <> 404 <h5> 405 <small> 406 No commit found, perhaps this is an{" "} 407 <Link 408 className="me-2" 409 href={{ 410 pathname: "/repositories/:repoId/changes", 411 params: { repoId: repo.id }, 412 query: { ref: reference.id }, 413 }} 414 > 415 uncommitted change 416 </Link> 417 ? 418 </small> 419 </h5> 420 </> 421 ); 422 } 423 424 return ( 425 <Modal show={show} onHide={onHide} size={"lg"}> 426 <Modal.Header closeButton> 427 <Modal.Title> 428 Last commit to modify <>{pathType}</> 429 </Modal.Title> 430 </Modal.Header> 431 <Modal.Body>{content}</Modal.Body> 432 </Modal> 433 ); 434 }; 435 436 const PathLink = ({ repoId, reference, path, children, presign = false, as = null }) => { 437 const name = path.split("/").pop(); 438 const link = linkToPath(repoId, reference.id, path, presign); 439 if (as === null) 440 return ( 441 <a href={link} download={name}> 442 {children} 443 </a> 444 ); 445 return React.createElement(as, { href: link, download: name }, children); 446 }; 447 448 const EntryRow = ({ config, repo, reference, path, entry, onDelete, showActions }) => { 449 let rowClass = "change-entry-row "; 450 switch (entry.diff_type) { 451 case "changed": 452 rowClass += "diff-changed"; 453 break; 454 case "added": 455 rowClass += "diff-added"; 456 break; 457 case "removed": 458 rowClass += "diff-removed"; 459 break; 460 default: 461 break; 462 } 463 464 const subPath = path.lastIndexOf("/") !== -1 ? path.substr(0, path.lastIndexOf("/")) : ""; 465 const buttonText = 466 subPath.length > 0 ? entry.path.substr(subPath.length + 1) : entry.path; 467 468 const params = { repoId: repo.id }; 469 const query = { ref: reference.id, path: entry.path }; 470 471 let button; 472 if (entry.path_type === "common_prefix") { 473 button = ( 474 <Link href={{ pathname: "/repositories/:repoId/objects", query, params }}> 475 {buttonText} 476 </Link> 477 ); 478 } else if (entry.diff_type === "removed") { 479 button = <span>{buttonText}</span>; 480 } else { 481 const filePathQuery = { 482 ref: query.ref, 483 path: query.path, 484 }; 485 button = ( 486 <Link 487 href={{ 488 pathname: "/repositories/:repoId/object", 489 query: filePathQuery, 490 params: params, 491 }} 492 > 493 {buttonText} 494 </Link> 495 ); 496 } 497 498 let size; 499 if (entry.diff_type === "removed" || entry.path_type === "common_prefix") { 500 size = <Na />; 501 } else { 502 size = ( 503 <OverlayTrigger 504 placement="bottom" 505 overlay={<Tooltip>{entry.size_bytes} bytes</Tooltip>} 506 > 507 <span>{humanSize(entry.size_bytes)}</span> 508 </OverlayTrigger> 509 ); 510 } 511 512 let modified; 513 if (entry.diff_type === "removed" || entry.path_type === "common_prefix") { 514 modified = <Na />; 515 } else { 516 modified = ( 517 <OverlayTrigger 518 placement="bottom" 519 overlay={ 520 <Tooltip> 521 {dayjs.unix(entry.mtime).format("MM/DD/YYYY HH:mm:ss")} 522 </Tooltip> 523 } 524 > 525 <span>{dayjs.unix(entry.mtime).fromNow()}</span> 526 </OverlayTrigger> 527 ); 528 } 529 530 let diffIndicator; 531 switch (entry.diff_type) { 532 case "removed": 533 diffIndicator = ( 534 <OverlayTrigger 535 placement="bottom" 536 overlay={<Tooltip>removed in diff</Tooltip>} 537 > 538 <span> 539 <TrashIcon /> 540 </span> 541 </OverlayTrigger> 542 ); 543 break; 544 case "added": 545 diffIndicator = ( 546 <OverlayTrigger 547 placement="bottom" 548 overlay={<Tooltip>added in diff</Tooltip>} 549 > 550 <span> 551 <PlusIcon /> 552 </span> 553 </OverlayTrigger> 554 ); 555 break; 556 case "changed": 557 diffIndicator = ( 558 <OverlayTrigger 559 placement="bottom" 560 overlay={<Tooltip>changed in diff</Tooltip>} 561 > 562 <span> 563 <PencilIcon /> 564 </span> 565 </OverlayTrigger> 566 ); 567 break; 568 default: 569 break; 570 } 571 572 let entryActions; 573 if (showActions && entry.diff_type !== "removed") { 574 entryActions = ( 575 <EntryRowActions 576 repo={repo} 577 reference={reference} 578 entry={entry} 579 onDelete={onDelete} 580 presign={config.config.pre_sign_support} 581 presign_ui={config.config.pre_sign_support_ui} 582 /> 583 ); 584 } 585 586 return ( 587 <> 588 <tr className={rowClass}> 589 <td className="diff-indicator">{diffIndicator}</td> 590 <td className="tree-path"> 591 {entry.path_type === "common_prefix" ? ( 592 <FileDirectoryIcon /> 593 ) : ( 594 <FileIcon /> 595 )}{" "} 596 {button} 597 </td> 598 <td className="tree-size">{size}</td> 599 <td className="tree-modified">{modified}</td> 600 <td className={"change-entry-row-actions"}>{entryActions}</td> 601 </tr> 602 </> 603 ); 604 }; 605 606 function pathParts(path, isPathToFile) { 607 let parts = path.split(/\//); 608 let resolved = []; 609 if (parts.length === 0) { 610 return resolved; 611 } 612 613 if (parts[parts.length - 1] === "" || !isPathToFile) { 614 parts = parts.slice(0, parts.length - 1); 615 } 616 617 // else 618 for (let i = 0; i < parts.length; i++) { 619 let currentPath = parts.slice(0, i + 1).join("/"); 620 if (currentPath.length > 0) { 621 currentPath = `${currentPath}/`; 622 } 623 resolved.push({ 624 name: parts[i], 625 path: currentPath, 626 }); 627 } 628 629 return resolved; 630 } 631 632 const buildPathURL = (params, query) => { 633 return { pathname: "/repositories/:repoId/objects", params, query }; 634 }; 635 636 export const URINavigator = ({ 637 repo, 638 reference, 639 path, 640 downloadUrl, 641 relativeTo = "", 642 pathURLBuilder = buildPathURL, 643 isPathToFile = false, 644 hasCopyButton = false 645 }) => { 646 const parts = pathParts(path, isPathToFile); 647 const params = { repoId: repo.id }; 648 649 return ( 650 <div className="d-flex"> 651 <div className="lakefs-uri flex-grow-1"> 652 {relativeTo === "" ? ( 653 <> 654 <strong>{"lakefs://"}</strong> 655 <Link href={{ pathname: "/repositories/:repoId/objects", params }}> 656 {repo.id} 657 </Link> 658 <strong>{"/"}</strong> 659 <Link 660 href={{ 661 pathname: "/repositories/:repoId/objects", 662 params, 663 query: { ref: reference.id }, 664 }} 665 > 666 {reference.type === RefTypeCommit 667 ? reference.id.substr(0, 12) 668 : reference.id} 669 </Link> 670 <strong>{"/"}</strong> 671 </> 672 ) : ( 673 <> 674 <Link href={pathURLBuilder(params, { path: "" })}>{relativeTo}</Link> 675 <strong>{"/"}</strong> 676 </> 677 )} 678 679 {parts.map((part, i) => { 680 const path = 681 parts 682 .slice(0, i + 1) 683 .map((p) => p.name) 684 .join("/") + "/"; 685 const query = { path, ref: reference.id }; 686 const edgeElement = 687 isPathToFile && i === parts.length - 1 ? ( 688 <span>{part.name}</span> 689 ) : ( 690 <> 691 <Link href={pathURLBuilder(params, query)}>{part.name}</Link> 692 <strong>{"/"}</strong> 693 </> 694 ); 695 return <span key={part.name}>{edgeElement}</span>; 696 })} 697 </div> 698 <div className="object-viewer-buttons"> 699 {hasCopyButton && 700 <ClipboardButton 701 text={`lakefs://${repo.id}/${reference.id}/${path}`} 702 variant="link" 703 size="sm" 704 onSuccess={noop} 705 onError={noop} 706 className={"me-1"} 707 tooltip={"copy URI to clipboard"}/>} 708 {(downloadUrl) && ( 709 <a 710 href={downloadUrl} 711 download={path.split('/').pop()} 712 className="btn btn-link btn-sm download-button me-1"> 713 <FaDownload /> 714 </a> 715 )} 716 </div> 717 </div> 718 ); 719 }; 720 721 const GetStarted = ({ config, onUpload, onImport }) => { 722 const importDisabled = !config.config.import_support; 723 return ( 724 <Container className="m-4 mb-5"> 725 <h2 className="mt-2">To get started with this repository:</h2> 726 727 <Row className="pt-2 ms-2"> 728 <Col> 729 <DotIcon className="me-1 mt-3" /> 730 <Button 731 variant="link" 732 className="mb-1" 733 disabled={importDisabled} 734 onClick={onImport} 735 > 736 Import 737 </Button> 738 data from {config.config.blockstore_type}. Or, see the 739 <a 740 href="https://docs.lakefs.io/howto/import.html" 741 target="_blank" 742 rel="noopener noreferrer" 743 > 744 docs 745 </a> 746 for other ways to import data to your repository. 747 </Col> 748 </Row> 749 750 <Row className="pt-2 ms-2"> 751 <Col> 752 <DotIcon className="me-1 mt-1" /> 753 <Button variant="link" className="mb-1" onClick={onUpload}> 754 Upload 755 </Button> 756 an object. 757 </Col> 758 </Row> 759 760 <Row className="pt-2 ms-2"> 761 <Col> 762 <DotIcon className="me-1 mt-1" /> 763 Use 764 <a 765 href="https://docs.lakefs.io/howto/copying.html#using-distcp" 766 target="_blank" 767 rel="noopener noreferrer" 768 > 769 DistCp 770 </a> 771 or 772 <a 773 href="https://docs.lakefs.io/howto/copying.html#using-rclone" 774 target="_blank" 775 rel="noopener noreferrer" 776 > 777 Rclone 778 </a> 779 to copy data into your repository. 780 </Col> 781 </Row> 782 </Container> 783 ); 784 }; 785 786 export const Tree = ({ 787 config, 788 repo, 789 reference, 790 results, 791 after, 792 onPaginate, 793 nextPage, 794 onUpload, 795 onImport, 796 onDelete, 797 showActions = false, 798 path = "", 799 }) => { 800 let body; 801 if (results.length === 0 && path === "" && reference.type === RefTypeBranch) { 802 // empty state! 803 body = ( 804 <GetStarted config={config} onUpload={onUpload} onImport={onImport} /> 805 ); 806 } else { 807 body = ( 808 <> 809 <Table borderless size="sm"> 810 <tbody> 811 {results.map((entry) => ( 812 <EntryRow 813 config={config} 814 key={entry.path} 815 entry={entry} 816 path={path} 817 repo={repo} 818 reference={reference} 819 showActions={showActions} 820 onDelete={onDelete} 821 /> 822 ))} 823 </tbody> 824 </Table> 825 </> 826 ); 827 } 828 829 return ( 830 <div className="tree-container"> 831 <Card> 832 <Card.Header> 833 <URINavigator path={path} repo={repo} reference={reference} hasCopyButton={true}/> 834 </Card.Header> 835 <Card.Body>{body}</Card.Body> 836 </Card> 837 838 <Paginator onPaginate={onPaginate} nextPage={nextPage} after={after} /> 839 </div> 840 ); 841 };