Skip to content
Open
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
89 changes: 89 additions & 0 deletions src/pages/Repository.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { handleChange } from "../forms";
import { SetupRepository } from "../components/SetupRepository";
import { CLIEquivalent } from "../components/CLIEquivalent";
import { cancelTask } from "../utils/taskutil";
import { parseBytes, formatBytes } from "../utils/formatutils";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCheck, faChevronCircleDown, faChevronCircleUp, faWindowClose } from "@fortawesome/free-solid-svg-icons";
import { Logs } from "../components/Logs";
Expand All @@ -26,6 +27,10 @@ export class Repository extends Component {
error: null,
provider: "",
description: "",
throttle: {
maxUploadSpeedBytesPerSecond: "",
maxDownloadSpeedBytesPerSecond: "",
},
};

this.mounted = false;
Expand All @@ -34,6 +39,8 @@ export class Repository extends Component {
this.handleChange = handleChange.bind(this);
this.fetchStatus = this.fetchStatus.bind(this);
this.fetchStatusWithoutSpinner = this.fetchStatusWithoutSpinner.bind(this);
this.fetchThrottle = this.fetchThrottle.bind(this);
this.updateThrottle = this.updateThrottle.bind(this);
}

componentDidMount() {
Expand Down Expand Up @@ -72,6 +79,9 @@ export class Repository extends Component {
window.setTimeout(() => {
this.fetchStatusWithoutSpinner();
}, 1000);
} else if (result.data.connected && !result.data.apiServerURL) {
// Only fetch throttle for direct repository connections (not API server connections)
this.fetchThrottle();
}
}
})
Expand All @@ -85,6 +95,44 @@ export class Repository extends Component {
});
}

fetchThrottle() {
axios
.get("/api/v1/repo/throttle")
.then((result) => {
if (this.mounted) {
this.setState({
throttle: {
maxUploadSpeedBytesPerSecond: formatBytes(result.data.maxUploadSpeedBytesPerSecond),
maxDownloadSpeedBytesPerSecond: formatBytes(result.data.maxDownloadSpeedBytesPerSecond),
},
});
}
})
.catch((error) => {
console.log("Unable to fetch throttle settings:", error);
});
}

updateThrottle() {
this.setState({ isLoading: true });

const throttleData = {
maxUploadSpeedBytesPerSecond: parseBytes(this.state.throttle.maxUploadSpeedBytesPerSecond),
maxDownloadSpeedBytesPerSecond: parseBytes(this.state.throttle.maxDownloadSpeedBytesPerSecond),
};

axios
.put("/api/v1/repo/throttle", throttleData)
.then((_result) => {
this.setState({ isLoading: false });
this.fetchThrottle();
})
.catch((error) => {
this.setState({ isLoading: false });
alert("Error updating throttle settings: " + (error.response?.data?.error || error.message));
});
}

disconnect() {
this.setState({ isLoading: true });
axios
Expand Down Expand Up @@ -287,6 +335,47 @@ export class Repository extends Component {
</Col>
</Row>
</Form>
{!this.state.status.apiServerURL && (
<>
<hr />
<h5>Upload/Download Speed Limits</h5>
<Form>
<Row>
<Form.Group as={Col} xs={3}>
<Form.Label>Maximum Upload Speed</Form.Label>
<Form.Control
type="text"
name="throttle.maxUploadSpeedBytesPerSecond"
value={this.state.throttle.maxUploadSpeedBytesPerSecond || ""}
onChange={this.handleChange}
placeholder="e.g., 1M, 100K"
size="sm"
/>
<Form.Text className="text-muted">Examples: 1.5M, 100K, 1G, or leave empty for unlimited</Form.Text>
</Form.Group>
<Form.Group as={Col} xs={3}>
<Form.Label>Maximum Download Speed</Form.Label>
<Form.Control
type="text"
name="throttle.maxDownloadSpeedBytesPerSecond"
value={this.state.throttle.maxDownloadSpeedBytesPerSecond || ""}
onChange={this.handleChange}
placeholder="e.g., 1M, 100K"
size="sm"
/>
<Form.Text className="text-muted">Examples: 1.5M, 100K, 1G, or leave empty for unlimited</Form.Text>
</Form.Group>
</Row>
<Row className="mt-3">
<Col>
<Button size="sm" variant="success" onClick={this.updateThrottle}>
Save Settings
</Button>
</Col>
</Row>
</Form>
</>
)}
<Row>
<Col>&nbsp;</Col>
</Row>
Expand Down
53 changes: 53 additions & 0 deletions src/utils/formatutils.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,59 @@ export function parseQuery(queryString) {
return query;
}

/**
* Parses a human-readable byte string (e.g., "1M", "100K") to bytes.
* @param {string|number} str - Byte string like "1M", "100K", "1G" or a number
* @returns {number} Number of bytes
*/
export function parseBytes(str) {
if (!str || str === "0" || str === 0) return 0;

const input = String(str).trim().toUpperCase();
const match = input.match(/^([0-9.]+)\s*([KMG])?$/);

if (!match) {
const num = parseFloat(input);
return isNaN(num) ? 0 : num;
}

const value = parseFloat(match[1]);
const unit = match[2] || "";

const multipliers = {
K: 1024,
M: 1024 * 1024,
G: 1024 * 1024 * 1024,
};

return value * (multipliers[unit] || 1);
}

/**
* Formats bytes to a human-readable string.
* @param {number} bytes - Number of bytes
* @returns {string} Formatted string like "1M", "100K", "1G", or empty string for 0
*/
export function formatBytes(bytes) {
if (!bytes || bytes === 0) return "";

const units = [
{ threshold: 1024 * 1024 * 1024, suffix: "G" },
{ threshold: 1024 * 1024, suffix: "M" },
{ threshold: 1024, suffix: "K" },
];

for (const { threshold, suffix } of units) {
if (bytes >= threshold) {
const value = bytes / threshold;
// Show integer if it divides evenly, otherwise 2 decimal places
return (Number.isInteger(value) ? value.toString() : value.toFixed(2)) + suffix;
}
}

return bytes.toString();
}

export function rfc3339TimestampForDisplay(n) {
if (!n) {
return "";
Expand Down
168 changes: 166 additions & 2 deletions tests/pages/Repository.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ beforeEach(() => {
axiosMock = setupAPIMock();
// Clear all mocks
vi.clearAllMocks();
// Mock throttle API to return default empty settings
axiosMock.onGet("/api/v1/repo/throttle").reply(200, {
maxUploadSpeedBytesPerSecond: 0,
maxDownloadSpeedBytesPerSecond: 0,
});
});

/**
Expand Down Expand Up @@ -189,8 +194,7 @@ describe("Repository component - connected state", () => {

await waitFor(() => {
// Get the description input specifically by its name attribute
const descriptionInput = screen.getByDisplayValue(""); // Empty value
expect(descriptionInput).toHaveAttribute("name", "status.description");
const descriptionInput = document.querySelector('input[name="status.description"]');
expect(descriptionInput).toHaveClass("is-invalid");
expect(screen.getByText("Description Is Required")).toBeInTheDocument();
});
Expand Down Expand Up @@ -358,3 +362,163 @@ describe("Repository component - CLI equivalent", () => {
});
});
});

describe("Repository component - throttle settings", () => {
test("displays throttle settings when connected directly", async () => {
axiosMock.onGet("/api/v1/repo/status").reply(200, connectedStatus);
axiosMock.onGet("/api/v1/repo/throttle").reply(200, {
maxUploadSpeedBytesPerSecond: 0,
maxDownloadSpeedBytesPerSecond: 0,
});

renderWithContext();

await waitFor(() => {
expect(screen.getByText("Upload/Download Speed Limits")).toBeInTheDocument();
const uploadInput = document.querySelector('input[name="throttle.maxUploadSpeedBytesPerSecond"]');
const downloadInput = document.querySelector('input[name="throttle.maxDownloadSpeedBytesPerSecond"]');
expect(uploadInput).toBeInTheDocument();
expect(downloadInput).toBeInTheDocument();
});
});

test("hides throttle settings when connected via API server", async () => {
axiosMock.onGet("/api/v1/repo/status").reply(200, {
...connectedStatus,
apiServerURL: "http://localhost:51515",
});

renderWithContext();

await waitFor(() => {
expect(screen.queryByText("Upload/Download Speed Limits")).not.toBeInTheDocument();
});
});

test("loads existing throttle settings", async () => {
axiosMock.onGet("/api/v1/repo/status").reply(200, connectedStatus);
axiosMock.onGet("/api/v1/repo/throttle").reply(200, {
maxUploadSpeedBytesPerSecond: 1048576, // 1M
maxDownloadSpeedBytesPerSecond: 2097152, // 2M
});

renderWithContext();

await waitFor(() => {
const uploadInput = document.querySelector('input[name="throttle.maxUploadSpeedBytesPerSecond"]');
const downloadInput = document.querySelector('input[name="throttle.maxDownloadSpeedBytesPerSecond"]');
expect(uploadInput).toHaveValue("1M");
expect(downloadInput).toHaveValue("2M");
});
});

test("updates throttle settings with various formats", async () => {
axiosMock.onGet("/api/v1/repo/status").reply(200, connectedStatus);
axiosMock.onGet("/api/v1/repo/throttle").reply(200, {
maxUploadSpeedBytesPerSecond: 0,
maxDownloadSpeedBytesPerSecond: 0,
});
axiosMock.onPut("/api/v1/repo/throttle").reply(200, {});

renderWithContext();

await waitFor(() => {
expect(screen.getByText("Upload/Download Speed Limits")).toBeInTheDocument();
});

const uploadInput = document.querySelector('input[name="throttle.maxUploadSpeedBytesPerSecond"]');
const downloadInput = document.querySelector('input[name="throttle.maxDownloadSpeedBytesPerSecond"]');
const saveButton = screen.getByText("Save Settings");

await userEvent.clear(uploadInput);
await userEvent.type(uploadInput, "100K");
await userEvent.clear(downloadInput);
await userEvent.type(downloadInput, "2G");
await userEvent.click(saveButton);

await waitFor(() => {
expect(axiosMock.history.put[0].data).toBe(
JSON.stringify({
maxUploadSpeedBytesPerSecond: 102400,
maxDownloadSpeedBytesPerSecond: 2147483648,
}),
);
});
});

test("allows empty values for unlimited speed", async () => {
axiosMock.onGet("/api/v1/repo/status").reply(200, connectedStatus);
axiosMock.onGet("/api/v1/repo/throttle").reply(200, {
maxUploadSpeedBytesPerSecond: 1048576,
maxDownloadSpeedBytesPerSecond: 2097152,
});
axiosMock.onPut("/api/v1/repo/throttle").reply(200, {});

renderWithContext();

await waitFor(() => {
const uploadInput = document.querySelector('input[name="throttle.maxUploadSpeedBytesPerSecond"]');
expect(uploadInput).toHaveValue("1M");
});

const uploadInput = document.querySelector('input[name="throttle.maxUploadSpeedBytesPerSecond"]');
const saveButton = screen.getByText("Save Settings");

await userEvent.clear(uploadInput);
await userEvent.click(saveButton);

await waitFor(() => {
expect(axiosMock.history.put[0].data).toBe(
JSON.stringify({
maxUploadSpeedBytesPerSecond: 0,
maxDownloadSpeedBytesPerSecond: 2097152,
}),
);
});
});

test("handles update errors", async () => {
axiosMock.onGet("/api/v1/repo/status").reply(200, connectedStatus);
axiosMock.onGet("/api/v1/repo/throttle").reply(200, {
maxUploadSpeedBytesPerSecond: 0,
maxDownloadSpeedBytesPerSecond: 0,
});
axiosMock.onPut("/api/v1/repo/throttle").reply(500, { error: "Failed" });

const alertMock = vi.spyOn(window, "alert").mockImplementation(() => {});

renderWithContext();

await waitFor(() => {
expect(screen.getByText("Upload/Download Speed Limits")).toBeInTheDocument();
});

const uploadInput = document.querySelector('input[name="throttle.maxUploadSpeedBytesPerSecond"]');
const saveButton = screen.getByText("Save Settings");

await userEvent.type(uploadInput, "5M");
await userEvent.click(saveButton);

await waitFor(() => {
expect(alertMock).toHaveBeenCalledWith(expect.stringContaining("Error updating throttle settings"));
});

alertMock.mockRestore();
});

test("handles throttle fetch errors gracefully", async () => {
axiosMock.onGet("/api/v1/repo/status").reply(200, connectedStatus);
axiosMock.onGet("/api/v1/repo/throttle").reply(500, { error: "Failed" });

const consoleLogMock = vi.spyOn(console, "log").mockImplementation(() => {});

renderWithContext();

await waitFor(() => {
expect(screen.getByText("Upload/Download Speed Limits")).toBeInTheDocument();
expect(consoleLogMock).toHaveBeenCalledWith("Unable to fetch throttle settings:", expect.any(Error));
});

consoleLogMock.mockRestore();
});
});
Loading