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
+
+ + + Maximum Upload Speed + + Examples: 1.5M, 100K, 1G, or leave empty for unlimited + + + Maximum Download Speed + + Examples: 1.5M, 100K, 1G, or leave empty for unlimited + + + + + + + +
+ + )}   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); + }); +});