Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ui): Support displaying storage values in base-2 #118

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export default class App extends Component {
return (
<Router>
<AppContext.Provider value={this}>
<UIPreferenceProvider initalValue={uiPrefs}>
<UIPreferenceProvider initialValue={uiPrefs}>
<Navbar expand="sm" variant="light">
<Navbar.Brand href="/"><img src="/kopia-flat.svg" className="App-logo" alt="logo" /></Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" />
Expand Down
5 changes: 3 additions & 2 deletions src/DirectoryItems.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React, { Component } from 'react';
import { Link } from "react-router-dom";
import MyTable from './Table';
import { objectLink, rfc3339TimestampForDisplay, sizeWithFailures } from './uiutil';
import { objectLink, rfc3339TimestampForDisplay } from './uiutil';
import SizeDisplay from './SizeDisplay';

function objectName(name, typeID) {
if (typeID === "d") {
Expand Down Expand Up @@ -49,7 +50,7 @@ export class DirectoryItems extends Component {
accessor: x => sizeInfo(x),
Header: "Size",
width: 100,
Cell: x => sizeWithFailures(x.cell.value, x.row.original.summ),
Cell: x => <SizeDisplay size={x.cell.value} summary={x.row.original.summ} />,
}, {
id: "files",
accessor: "summ.files",
Expand Down
7 changes: 5 additions & 2 deletions src/EstimateResults.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import React, { Component } from 'react';
import Button from 'react-bootstrap/Button';
import Spinner from 'react-bootstrap/esm/Spinner';
import Form from 'react-bootstrap/Form';
import SizeDisplay from './SizeDisplay';
import { TaskLogs } from './TaskLogs';
import { cancelTask, redirectIfNotConnected, sizeDisplayName } from './uiutil';
import { cancelTask, redirectIfNotConnected } from './uiutil';

export class EstimateResults extends Component {
constructor() {
Expand Down Expand Up @@ -97,7 +98,9 @@ export class EstimateResults extends Component {

return <>
{task.counters && <Form.Text className="estimateResults">
{this.taskStatusDescription(task)} Bytes: <b>{sizeDisplayName(task.counters["Bytes"]?.value)}</b> (<b>{sizeDisplayName(task.counters["Excluded Bytes"]?.value)}</b> excluded)
{this.taskStatusDescription(task)}{' '}
Bytes: <b><SizeDisplay size={task.counters.Bytes?.value} /></b>{' '}
(<b><SizeDisplay size={task.counters["Excluded Bytes"]?.value} /></b> excluded)
Files: <b>{task.counters["Files"]?.value}</b> (<b>{task.counters["Excluded Files"]?.value}</b> excluded)
Directories: <b>{task.counters["Directories"]?.value}</b> (<b>{task.counters["Excluded Directories"]?.value}</b> excluded)
Errors: <b>{task.counters["Errors"]?.value}</b> (<b>{task.counters["Ignored Errors"]?.value}</b> ignored)
Expand Down
36 changes: 36 additions & 0 deletions src/SizeDisplay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React, { useContext } from 'react';
import { UIPreferencesContext } from './contexts/UIPreferencesContext';
import { getErrorList, sizeDisplayName, Summary } from './utils/ui';

type SizeDisplayProps = {
size?: number;
summary?: Summary;
};
const SizeDisplay = ({ size, summary }: SizeDisplayProps) => {
const { bytesStringBase2 } = useContext(UIPreferencesContext);

if (size === undefined) return null;

const sizeDisplay = sizeDisplayName(size, bytesStringBase2);
const errorList = getErrorList(summary);

return (
<span>
{sizeDisplay}
{errorList && (
<>
&nbsp;
<FontAwesomeIcon
color="red"
icon={faExclamationTriangle}
title={errorList}
/>
</>
)}
</span>
);
};

export default SizeDisplay;
5 changes: 3 additions & 2 deletions src/SnapshotsTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import Col from 'react-bootstrap/Col';
import Spinner from 'react-bootstrap/Spinner';
import { Link } from "react-router-dom";
import MyTable from './Table';
import { CLIEquivalent, compare, errorAlert, GoBackButton, objectLink, parseQuery, redirectIfNotConnected, rfc3339TimestampForDisplay, sizeWithFailures, sourceQueryStringParams } from './uiutil';
import { CLIEquivalent, compare, errorAlert, GoBackButton, objectLink, parseQuery, redirectIfNotConnected, rfc3339TimestampForDisplay, sourceQueryStringParams } from './uiutil';
import { faSync, faThumbtack } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import Modal from 'react-bootstrap/Modal';
import { faFileAlt } from '@fortawesome/free-regular-svg-icons';
import SizeDisplay from './SizeDisplay';

function pillVariant(tag) {
if (tag.startsWith("latest-")) {
Expand Down Expand Up @@ -362,7 +363,7 @@ export class SnapshotsTable extends Component {
Header: 'Size',
accessor: 'summary.size',
width: 100,
Cell: x => sizeWithFailures(x.cell.value, x.row.original.summary),
Cell: x => <SizeDisplay size={x.cell.value} summary={x.row.original.summary} />
}, {
Header: 'Files',
accessor: 'summary.files',
Expand Down
28 changes: 20 additions & 8 deletions src/SourcesTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,19 @@ import Dropdown from 'react-bootstrap/Dropdown';
import Row from 'react-bootstrap/Row';
import Spinner from 'react-bootstrap/Spinner';
import { Link } from 'react-router-dom';
import SizeDisplay from './SizeDisplay';
import { UIPreferencesContext } from './contexts/UIPreferencesContext';
import { handleChange } from './forms';
import MyTable from './Table';
import { CLIEquivalent, compare, errorAlert, ownerName, policyEditorURL, redirectIfNotConnected, sizeDisplayName, sizeWithFailures, sourceQueryStringParams } from './uiutil';
import { sizeDisplayName } from './utils/ui';
import { CLIEquivalent, compare, errorAlert, ownerName, policyEditorURL, redirectIfNotConnected, sourceQueryStringParams } from './uiutil';

const localSnapshots = "Local Snapshots"
const allSnapshots = "All Snapshots"

export class SourcesTable extends Component {
static contextType = UIPreferencesContext;

constructor() {
super();
this.state = {
Expand Down Expand Up @@ -110,15 +115,15 @@ export class SourcesTable extends Component {
let title = "";
let totals = "";
if (u) {
title = " hashed " + u.hashedFiles + " files (" + sizeDisplayName(u.hashedBytes) + ")\n" +
" cached " + u.cachedFiles + " files (" + sizeDisplayName(u.cachedBytes) + ")\n" +
title = " hashed " + u.hashedFiles + " files (" + sizeDisplayName(u.hashedBytes, this.context.bytesStringBase2) + ")\n" +
" cached " + u.cachedFiles + " files (" + sizeDisplayName(u.cachedBytes, this.context.bytesStringBase2) + ")\n" +
" dir " + u.directory;

const totalBytes = u.hashedBytes + u.cachedBytes;

totals = sizeDisplayName(totalBytes);
totals = sizeDisplayName(totalBytes, this.context.bytesStringBase2);
if (u.estimatedBytes) {
totals += "/" + sizeDisplayName(u.estimatedBytes);
totals += "/" + sizeDisplayName(u.estimatedBytes, this.context.bytesStringBase2);

const percent = Math.round(totalBytes * 1000.0 / u.estimatedBytes) / 10.0;
if (percent <= 100) {
Expand Down Expand Up @@ -231,9 +236,16 @@ export class SourcesTable extends Component {
Header: 'Size',
width: 120,
accessor: x => x.lastSnapshot ? x.lastSnapshot.stats.totalSize : 0,
Cell: x => sizeWithFailures(
x.cell.value,
x.row.original.lastSnapshot && x.row.original.lastSnapshot.rootEntry ? x.row.original.lastSnapshot.rootEntry.summ : null),
Cell: x => (
<SizeDisplay
size={x.cell.value}
summary={
x.row.original.lastSnapshot?.rootEntry
? x.row.original.lastSnapshot.rootEntry.summ
: null
}
/>
),
}, {
id: 'lastSnapshotTime',
Header: 'Last Snapshot',
Expand Down
20 changes: 16 additions & 4 deletions src/Table.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useContext, useEffect } from 'react';
import Dropdown from 'react-bootstrap/Dropdown';
import Pagination from 'react-bootstrap/Pagination';
import Stack from 'react-bootstrap/Stack';
import Table from 'react-bootstrap/Table';
import { usePagination, useSortBy, useTable } from 'react-table';
import { PAGE_SIZES, UIPreferencesContext } from './contexts/UIPreferencesContext';
Expand Down Expand Up @@ -47,7 +48,7 @@ function paginationItems(count, active, gotoPage) {
}

export default function MyTable({ columns, data }) {
const { pageSize, setPageSize } = useContext(UIPreferencesContext);
const { bytesStringBase2, setBytesStringBase2, pageSize, setPageSize } = useContext(UIPreferencesContext);

const {
getTableProps,
Expand Down Expand Up @@ -93,8 +94,8 @@ export default function MyTable({ columns, data }) {
<Pagination.Last onClick={() => gotoPage(pageCount - 1)} disabled={!canNextPage} />
</Pagination>)}
</>
<>
<Dropdown style={{ marginBottom: '1em' }}>
<Stack direction="horizontal" gap={3} style={{ marginBottom: '1em' }}>
<Dropdown>
<Dropdown.Toggle size="sm">
Page Size: {pageSize}
</Dropdown.Toggle>
Expand All @@ -105,7 +106,18 @@ export default function MyTable({ columns, data }) {
</Dropdown.Item>))}
</Dropdown.Menu>
</Dropdown>
</>
<Dropdown>
<Dropdown.Toggle size="sm">
Units: {bytesStringBase2 ? 'Binary' : 'Decimal'}
</Dropdown.Toggle>
<Dropdown.Menu>
{[false, true].map(isBase2 => (
<Dropdown.Item size="sm" key={`base-2-${isBase2}`} onClick={() => setBytesStringBase2(isBase2)}>
Units: {isBase2 ? 'Binary' : 'Decimal'}
</Dropdown.Item>))}
</Dropdown.Menu>
</Dropdown>
</Stack>
</>;

return (
Expand Down
5 changes: 3 additions & 2 deletions src/TaskDetails.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import Row from 'react-bootstrap/Row';
import Table from 'react-bootstrap/Table';
import Spinner from 'react-bootstrap/Spinner';
import { TaskLogs } from './TaskLogs';
import { cancelTask, formatDuration, GoBackButton, redirectIfNotConnected, sizeDisplayName } from './uiutil';
import { cancelTask, formatDuration, GoBackButton, redirectIfNotConnected } from './uiutil';
import SizeDisplay from './SizeDisplay';

export class TaskDetails extends Component {
constructor() {
Expand Down Expand Up @@ -113,7 +114,7 @@ export class TaskDetails extends Component {

let formatted = c.value.toLocaleString();
if (c.units === "bytes") {
formatted = sizeDisplayName(c.value);
formatted = <SizeDisplay size={c.value} />;
}

return <tr key={label}><td>{label}</td><td>{formatted}</td></tr>;
Expand Down
18 changes: 14 additions & 4 deletions src/contexts/UIPreferencesContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ export type Theme = "dark" | "light";
export type PageSize = 10 | 20 | 30 | 40 | 50 | 100;

export interface UIPreferences {
get bytesStringBase2(): boolean
get pageSize(): PageSize
get theme(): Theme
setBytesStringBase2: (isSet: boolean) => void
setTheme: (theme: Theme) => void
setPageSize: (pageSize: number) => void
}

interface SerializedUIPreferences {
bytesStringBase2?: boolean
pageSize?: number
theme: Theme | undefined
}
Expand All @@ -23,7 +26,7 @@ export const UIPreferencesContext = React.createContext<UIPreferences>({} as UIP

export interface UIPreferenceProviderProps {
children: ReactNode,
initalValue: UIPreferences | undefined
initialValue: Partial<UIPreferences> | undefined
}

function getDefaultTheme(): Theme {
Expand Down Expand Up @@ -54,14 +57,17 @@ function normalizePageSize(pageSize: number): PageSize {

const PREFERENCES_URL = '/api/v1/ui-preferences';

const DEFAULT_PREFERENCES = { pageSize: PAGE_SIZES[0], theme: getDefaultTheme() } as SerializedUIPreferences;
const DEFAULT_PREFERENCES = { bytesStringBase2: false, pageSize: PAGE_SIZES[0], theme: getDefaultTheme() } as SerializedUIPreferences;

export function UIPreferenceProvider(props: UIPreferenceProviderProps) {
const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES);

useEffect(() => {
axios.get(PREFERENCES_URL).then(result => {
let storedPreferences = result.data as SerializedUIPreferences;
if (storedPreferences.bytesStringBase2 === undefined) {
storedPreferences.bytesStringBase2 = DEFAULT_PREFERENCES.bytesStringBase2;
}
if (!storedPreferences.theme || (storedPreferences.theme as string) === "") {
storedPreferences.theme = getDefaultTheme();
}
Expand All @@ -80,9 +86,13 @@ export function UIPreferenceProvider(props: UIPreferenceProviderProps) {
return;
}

axios.put(PREFERENCES_URL, preferences).then(result => {}).catch(err => console.error(err));
axios.put(PREFERENCES_URL, preferences).catch(err => console.error(err));
}, [preferences]);

const setBytesStringBase2 = (bytesStringBase2: boolean) => setPreferences(oldPreferences => {
return { ...oldPreferences, bytesStringBase2 };
});

const setTheme = (theme: Theme) => setPreferences(oldPreferences => {
return { ...oldPreferences, theme };
});
Expand All @@ -91,7 +101,7 @@ export function UIPreferenceProvider(props: UIPreferenceProviderProps) {
return { ...oldPreferences, pageSize };
});

const providedValue = { ...preferences, setTheme, setPageSize } as UIPreferences;
const providedValue = { ...preferences, setBytesStringBase2, setTheme, setPageSize } as UIPreferences;

return <UIPreferencesContext.Provider value={providedValue}>
{props.children}
Expand Down
52 changes: 52 additions & 0 deletions src/tests/SizeDisplay.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { RenderResult, screen } from '@testing-library/react';
import SizeDisplay from 'src/SizeDisplay';
import { Summary } from 'src/utils/ui';
//@ts-ignore
import { renderWithContext } from './testutils';

const renderSizeDisplay = (size?: number, summary?: Summary, bytesStringBase2 = false) =>
renderWithContext(<SizeDisplay {...{ size, summary }} />, { bytesStringBase2 });


const getErrorIcon = (container: HTMLElement) =>
container.querySelector('[data-icon="triangle-exclamation"]');

const expectNoErrorIcon = (container: HTMLElement) =>
expect(getErrorIcon(container)).toBeFalsy();

describe('SizeDisplay', () => {
it('displays nothing if the size is undefined', async () => {
const { container } = await renderSizeDisplay();

expect(container.childElementCount).toEqual(0);
});

it('displays the size in base-10', async () => {
const { container } = await renderSizeDisplay(900);

screen.getByText('0.9 KB');
expectNoErrorIcon(container);
});

it('displays the size in base-2', async () => {
const { container } = await renderSizeDisplay(900, undefined, true);

screen.getByText('900 B');
expectNoErrorIcon(container);
});

it('displays errors with an icon', async () => {
const rootError = { path: '/', error: 'root path' };
const homeError = { path: '/home', error: 'home path' };

const { container } = await renderSizeDisplay(900, {
errors: [rootError, homeError],
numFailed: 2,
});

screen.getByText('0.9 KB');
expect(getErrorIcon(container)?.querySelector('title')?.textContent).toEqual(
'Encountered 2 errors:\n\n- /: root path\n- /home: home path'
);
});
});
Loading