diff --git a/src/pages/Repository.jsx b/src/pages/Repository.jsx
index 404fd5d7..dcac0093 100644
--- a/src/pages/Repository.jsx
+++ b/src/pages/Repository.jsx
@@ -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";
@@ -26,6 +27,10 @@ export class Repository extends Component {
error: null,
provider: "",
description: "",
+ throttle: {
+ maxUploadSpeedBytesPerSecond: "",
+ maxDownloadSpeedBytesPerSecond: "",
+ },
};
this.mounted = false;
@@ -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() {
@@ -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();
}
}
})
@@ -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
@@ -287,6 +335,47 @@ export class Repository extends Component {
+ {!this.state.status.apiServerURL && (
+ <>
+
+ Upload/Download Speed Limits
+
+ >
+ )}
diff --git a/src/utils/formatutils.js b/src/utils/formatutils.js
index cb65de00..b6132478 100644
--- a/src/utils/formatutils.js
+++ b/src/utils/formatutils.js
@@ -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 "";
diff --git a/tests/pages/Repository.test.jsx b/tests/pages/Repository.test.jsx
index 05dbfc79..f4972e33 100644
--- a/tests/pages/Repository.test.jsx
+++ b/tests/pages/Repository.test.jsx
@@ -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,
+ });
});
/**
@@ -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();
});
@@ -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();
+ });
+});
diff --git a/tests/utils/formatutils.test.js b/tests/utils/formatutils.test.js
index 89c39f88..afa05c34 100644
--- a/tests/utils/formatutils.test.js
+++ b/tests/utils/formatutils.test.js
@@ -9,6 +9,8 @@ import {
formatOwnerName,
compare,
formatDuration,
+ parseBytes,
+ formatBytes,
} from "../../src/utils/formatutils";
describe("formatMilliseconds", () => {
@@ -481,3 +483,69 @@ describe("formatDuration", () => {
expect(formatDuration(from, to, true)).toBe("1m 30s");
});
});
+
+describe("parseBytes", () => {
+ describe("edge cases", () => {
+ it.each([
+ ["", 0],
+ ["0", 0],
+ [0, 0],
+ [null, 0],
+ [undefined, 0],
+ ["invalid", 0],
+ ["X", 0],
+ ["abc", 0],
+ ])("returns 0 for %p", (input, expected) => {
+ expect(parseBytes(input)).toBe(expected);
+ });
+ });
+
+ describe("unit parsing", () => {
+ it.each([
+ ["1K", 1024],
+ ["1k", 1024],
+ ["100K", 102400],
+ ["1.5K", 1536],
+ ["1M", 1048576],
+ ["1m", 1048576],
+ ["100M", 104857600],
+ ["2.5M", 2621440],
+ ["1G", 1073741824],
+ ["1g", 1073741824],
+ ["2G", 2147483648],
+ ["0.5G", 536870912],
+ [" 1M ", 1048576],
+ ["1 M", 1048576],
+ ["1024", 1024],
+ ["1048576", 1048576],
+ ])("parses '%s' as %i bytes", (input, expected) => {
+ expect(parseBytes(input)).toBe(expected);
+ });
+ });
+});
+
+describe("formatBytes", () => {
+ it.each([
+ [0, ""],
+ [null, ""],
+ [undefined, ""],
+ ])("returns empty string for %p", (input, expected) => {
+ expect(formatBytes(input)).toBe(expected);
+ });
+
+ it.each([
+ [512, "512"],
+ [1024, "1K"],
+ [102400, "100K"],
+ [1536, "1.50K"],
+ [1048576, "1M"],
+ [104857600, "100M"],
+ [2621440, "2.50M"],
+ [1572864, "1.50M"],
+ [1073741824, "1G"],
+ [2147483648, "2G"],
+ [1610612736, "1.50G"],
+ ])("formats %i bytes as '%s'", (input, expected) => {
+ expect(formatBytes(input)).toBe(expected);
+ });
+});