diff --git a/src/gui/src/UI/Dashboard/TabHome.js b/src/gui/src/UI/Dashboard/TabHome.js index a69a7ea97..2cc450142 100644 --- a/src/gui/src/UI/Dashboard/TabHome.js +++ b/src/gui/src/UI/Dashboard/TabHome.js @@ -20,6 +20,7 @@ import UIWindowSaveAccount from '../UIWindowSaveAccount.js'; import { formatCredits, formatDollarsFromMicrocents, usageIsCredits } from './credits.js'; import { addonCreditsRemaining, monthlyUsageOrThrow, usageBudget } from './usageBudget.js'; +import { getMonthlyUsage, getStorageSpace } from './usageRequests.js'; import { appIconAttrs } from '../../helpers/appIcon.js'; import { filterLaunchableApps } from '../../helpers/launchAppsList.js'; import { isOrgSeat, orgSeatTeamName } from './orgSeat.js'; @@ -613,7 +614,7 @@ const TabHome = { // Load storage data try { - const res = await puter.fs.space(); + const res = await getStorageSpace(); // Guard capacity 0 — 0/0 would render literally as "NaN%". let usage_percentage = res.capacity ? ((res.used / res.capacity) * 100).toFixed(0) @@ -644,7 +645,7 @@ const TabHome = { // Load monthly usage data try { - const res = monthlyUsageOrThrow(await puter.auth.getMonthlyUsage()); + const res = monthlyUsageOrThrow(await getMonthlyUsage()); const budget = usageBudget(res.usage, res.allowanceInfo); const addonLeft = addonCreditsRemaining(res.allowanceInfo); // The server reports credits (already scaled) or raw amounts diff --git a/src/gui/src/UI/Dashboard/TabUsage.js b/src/gui/src/UI/Dashboard/TabUsage.js index 43872f5ba..1dc77d361 100644 --- a/src/gui/src/UI/Dashboard/TabUsage.js +++ b/src/gui/src/UI/Dashboard/TabUsage.js @@ -20,6 +20,7 @@ import { formatCredits, formatDollarsFromMicrocents, usageIsCredits } from './credits.js'; import { isOrgSeat } from './orgSeat.js'; import { addonCreditsRemaining, monthlyUsageOrThrow, usageBudget } from './usageBudget.js'; +import { getMonthlyUsage, getStorageSpace } from './usageRequests.js'; // Whether the server reported credits — decides whether the usage surfaces // render credits or fall back to dollars. @@ -302,7 +303,7 @@ function renderUsageTable () { } async function update_usage_details ($el_window) { - const monthlyUsagePromise = puter.auth.getMonthlyUsage().then(monthlyUsageOrThrow).then(res => { + const monthlyUsagePromise = getMonthlyUsage().then(monthlyUsageOrThrow).then(res => { const budget = usageBudget(res.usage, res.allowanceInfo); const addonLeft = addonCreditsRemaining(res.allowanceInfo); // The server reports credits (already scaled) or raw amounts (no @@ -372,7 +373,7 @@ async function update_usage_details ($el_window) { } }); - const spacePromise = puter.fs.space().then(res => { + const spacePromise = getStorageSpace().then(res => { // Guard capacity 0 — otherwise 0/0 renders literally as "NaN%". let usage_percentage = res.capacity ? (res.used / res.capacity * 100).toFixed(0) : '0'; usage_percentage = usage_percentage > 100 ? 100 : usage_percentage; diff --git a/src/gui/src/UI/Dashboard/usageRequests.js b/src/gui/src/UI/Dashboard/usageRequests.js new file mode 100644 index 000000000..645870ffb --- /dev/null +++ b/src/gui/src/UI/Dashboard/usageRequests.js @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import shareInFlight from '../../helpers/shareInFlight.js'; + +// Home and Usage both show these and both load them when the dashboard +// opens; a load overlapping the other's shares its request. +export const getMonthlyUsage = shareInFlight(() => puter.auth.getMonthlyUsage()); +export const getStorageSpace = shareInFlight(() => puter.fs.space()); diff --git a/src/gui/src/helpers/shareInFlight.js b/src/gui/src/helpers/shareInFlight.js new file mode 100644 index 000000000..5825a6433 --- /dev/null +++ b/src/gui/src/helpers/shareInFlight.js @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Wraps an async call so overlapping callers share one call: until the + * first settles, every caller gets its promise. The next call after it + * settles starts afresh, so nothing is cached. + * + * @template T + * @param {() => Promise} fn + * @returns {() => Promise} + */ +export default function shareInFlight (fn) { + let pending = null; + return () => { + pending ??= Promise.resolve().then(fn).finally(() => { + pending = null; + }); + return pending; + }; +} diff --git a/src/gui/src/helpers/shareInFlight.test.js b/src/gui/src/helpers/shareInFlight.test.js new file mode 100644 index 000000000..f51b24c19 --- /dev/null +++ b/src/gui/src/helpers/shareInFlight.test.js @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, expect, it, vi } from 'vitest'; +import shareInFlight from './shareInFlight.js'; + +describe('shareInFlight', () => { + it('gives overlapping callers the same call', async () => { + const fn = vi.fn(async () => 'answer'); + const shared = shareInFlight(fn); + const [a, b] = await Promise.all([shared(), shared()]); + expect(fn).toHaveBeenCalledTimes(1); + expect(a).toBe('answer'); + expect(b).toBe('answer'); + }); + + it('calls again once the previous call has settled', async () => { + const fn = vi.fn(async () => 'answer'); + const shared = shareInFlight(fn); + await shared(); + await shared(); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('shares a rejection, then lets the next call retry', async () => { + const fn = vi.fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce('answer'); + const shared = shareInFlight(fn); + const first = shared(); + const second = shared(); + await expect(first).rejects.toThrow('boom'); + await expect(second).rejects.toThrow('boom'); + await expect(shared()).resolves.toBe('answer'); + expect(fn).toHaveBeenCalledTimes(2); + }); +});