diff --git a/src/gui/src/UI/Dashboard/TabUsage.js b/src/gui/src/UI/Dashboard/TabUsage.js new file mode 100644 index 000000000..9674b2a38 --- /dev/null +++ b/src/gui/src/UI/Dashboard/TabUsage.js @@ -0,0 +1,277 @@ +/** + * 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 . + */ + +// Sort state for the usage table +let usageTableSortState = { + column: 'cost', // default sort by cost + direction: 'desc' // default descending (highest cost first) +}; +let usageTableData = []; // Store raw data for sorting + +const TabUsage = { + id: 'usage', + label: 'Usage', + icon: ` `, + html: () => { + return ` +

${i18n('usage')}

+
+
+

${i18n('Storage')}

+
+ + used of + + +
+
+
+ +
+
+
+
+
+

${i18n('Resources')}

+
+ + used of + +
+
+
+
+ +
+
+
+
+ View usage details +
+ +
+
`; + }, + init: ($el_window) => { + update_usage_details($el_window); + $($el_window).find('.update-usage-details').on('click', function () { + update_usage_details($el_window); + }); + + // Scoped click handler for usage details toggle + $($el_window).on('click', '.driver-usage-details', function () { + const $container = $(this).closest('.driver-usage'); + $container.find('.driver-usage-details-content').toggleClass('active'); + $(this).toggleClass('active'); + + // change the text of the driver-usage-details-text depending on the class + if ( $(this).hasClass('active') ) { + $(this).find('.driver-usage-details-text').text('Hide usage details'); + } else { + $(this).find('.driver-usage-details-text').text('View usage details'); + } + }); + + // Click handler for sortable table headers + $($el_window).on('click', '.driver-usage-details-content-table th[data-sort]', function () { + const column = $(this).data('sort'); + + // Toggle direction if same column, otherwise default to descending + if ( usageTableSortState.column === column ) { + usageTableSortState.direction = usageTableSortState.direction === 'asc' ? 'desc' : 'asc'; + } else { + usageTableSortState.column = column; + usageTableSortState.direction = 'desc'; + } + + renderUsageTable(); + }); + }, +}; + +function getSortIcon(column) { + const isActive = usageTableSortState.column === column; + const direction = usageTableSortState.direction; + + if ( !isActive ) { + // Neutral sort icon (both arrows, dimmed) + return ` + + + + `; + } else if ( direction === 'asc' ) { + // Ascending icon + return ` + + + + `; + } else { + // Descending icon + return ` + + + + `; + } +} + +function renderUsageTable() { + // Sort the data + const sortedData = [...usageTableData].sort((a, b) => { + let aVal, bVal; + + switch ( usageTableSortState.column ) { + case 'resource': + aVal = a.resource.toLowerCase(); + bVal = b.resource.toLowerCase(); + break; + case 'cost': + default: + aVal = a.rawCost; + bVal = b.rawCost; + break; + } + + if ( aVal < bVal ) return usageTableSortState.direction === 'asc' ? -1 : 1; + if ( aVal > bVal ) return usageTableSortState.direction === 'asc' ? 1 : -1; + return 0; + }); + + // Build the table + let h = ''; + + h += ` + + + + + + `; + + h += ''; + for ( const row of sortedData ) { + h += ` + + + + + `; + } + h += ''; + h += '
Resource ${getSortIcon('resource')}UnitsCost ${getSortIcon('cost')}
${row.resource}${row.formattedUnits}${row.formattedCost}
'; + + $('.driver-usage-details-content').html(h); +} + +async function update_usage_details ($el_window) { + // Add spinning animation and record start time + const startTime = Date.now(); + $($el_window).find('.update-usage-details-icon').css('animation', 'spin 1s linear infinite'); + + const monthlyUsagePromise = puter.auth.getMonthlyUsage().then(res => { + let monthlyAllowance = res.allowanceInfo?.monthUsageAllowance; + let remaining = res.allowanceInfo?.remaining; + let totalUsage = monthlyAllowance - remaining; + let totalUsagePercentage = (totalUsage / monthlyAllowance * 100).toFixed(0); + + $('#total-usage').html(window.number_format(totalUsage / 100_000_000, { decimals: 2, prefix: '$' })); + $('#total-capacity').html(window.number_format(monthlyAllowance / 100_000_000, { decimals: 2, prefix: '$' })); + $('.usage-progbar-percent').html(`${totalUsagePercentage }%`); + $('.usage-progbar').css('width', `${totalUsagePercentage }%`); + + // Store raw data for sorting + usageTableData = []; + for ( let key in res.usage ) { + // value must be object + if ( typeof res.usage[key] !== 'object' ) { + continue; + } + + const rawUnits = res.usage[key].units; + const rawCost = res.usage[key].cost; + + // Format units for display + let formattedUnits; + if ( key.startsWith('filesystem:') && key.endsWith(':bytes') ) { + formattedUnits = window.byte_format(rawUnits); + } else { + formattedUnits = window.number_format(rawUnits, { decimals: 0, thousandSeparator: ',' }); + } + + usageTableData.push({ + resource: key, + rawUnits: rawUnits, + formattedUnits: formattedUnits, + rawCost: rawCost, + formattedCost: window.number_format(rawCost / 100_000_000, { decimals: 2, prefix: '$' }) + }); + } + + renderUsageTable(); + }); + + const spacePromise = puter.fs.space().then(res => { + let usage_percentage = (res.used / res.capacity * 100).toFixed(0); + usage_percentage = usage_percentage > 100 ? 100 : usage_percentage; + + let general_used = res.used; + + let host_usage_percentage = 0; + if ( res.host_used ) { + $('#storage-puter-used').html(window.byte_format(res.used)); + $('#storage-puter-used-w').show(); + + general_used = res.host_used; + host_usage_percentage = ((res.host_used - res.used) / res.capacity * 100).toFixed(0); + } + + $('#storage-used').html(window.byte_format(general_used)); + $('#storage-capacity').html(window.byte_format(res.capacity)); + $('#storage-used-percent').html( + `${usage_percentage }%${ + host_usage_percentage > 0 + ? ` / ${ host_usage_percentage }%` : ''}`); + $('#storage-bar').css('width', `${usage_percentage }%`); + $('#storage-bar-host').css('width', `${host_usage_percentage }%`); + if ( usage_percentage >= 100 ) { + $('#storage-bar').css({ + 'border-top-right-radius': '3px', + 'border-bottom-right-radius': '3px', + }); + } + }); + + // Wait for both promises to complete + await Promise.all([monthlyUsagePromise, spacePromise]); + + // Ensure spinning continues for at least 1 second + const elapsed = Date.now() - startTime; + const minDuration = 1000; // 1 second + if ( elapsed < minDuration ) { + await new Promise(resolve => setTimeout(resolve, minDuration - elapsed)); + } + + // Remove spinning animation + $($el_window).find('.update-usage-details-icon').css('animation', ''); +} + +export default TabUsage; \ No newline at end of file diff --git a/src/gui/src/UI/Dashboard/UIDashboard.js b/src/gui/src/UI/Dashboard/UIDashboard.js index 021be251b..13e7fd188 100644 --- a/src/gui/src/UI/Dashboard/UIDashboard.js +++ b/src/gui/src/UI/Dashboard/UIDashboard.js @@ -28,11 +28,13 @@ import UIWindowFeedback from '../UIWindowFeedback.js'; // Import tab modules import TabFiles from './TabFiles.js'; import TabApps from './TabApps.js'; +import TabUsage from './TabUsage.js'; // Registry of all available tabs const tabs = [ TabFiles, TabApps, + TabUsage, ]; async function UIDashboard (options) { @@ -51,14 +53,20 @@ async function UIDashboard (options) { h += '
'; // Navigation items container h += '
'; - for ( let i = 0; i < tabs.length; i++ ) { - const tab = tabs[i]; - const isActive = i === 0 ? ' active' : ''; - h += `
`; - h += tab.icon; - h += tab.label; + h += `
`; + h += TabFiles.icon; + h += TabFiles.label; + h += '
'; + + h += `
`; + h += TabApps.icon; + h += TabApps.label; + h += '
'; + + h += `
`; + h += TabUsage.icon; + h += TabUsage.label; h += '
'; - } h += '
'; // User options button at bottom diff --git a/src/gui/src/UI/Settings/UITabUsage.js b/src/gui/src/UI/Settings/UITabUsage.js index 71b9f65ab..71172b823 100644 --- a/src/gui/src/UI/Settings/UITabUsage.js +++ b/src/gui/src/UI/Settings/UITabUsage.js @@ -68,21 +68,23 @@ export default { $($el_window).find('.update-usage-details').on('click', function () { update_usage_details($el_window); }); + + // Scoped click handler for usage details toggle + $($el_window).on('click', '.driver-usage-details', function () { + const $container = $(this).closest('.driver-usage'); + $container.find('.driver-usage-details-content').toggleClass('active'); + $(this).toggleClass('active'); + + // change the text of the driver-usage-details-text depending on the class + if ( $(this).hasClass('active') ) { + $(this).find('.driver-usage-details-text').text('Hide usage details'); + } else { + $(this).find('.driver-usage-details-text').text('View usage details'); + } + }); }, }; -$(document).on('click', '.driver-usage-details', function () { - $('.driver-usage-details-content').toggleClass('active'); - $('.driver-usage-details').toggleClass('active'); - - // change the text of the driver-usage-details-text depending on the class - if ( $('.driver-usage-details').hasClass('active') ) { - $('.driver-usage-details-text').text('Hide usage details'); - } else { - $('.driver-usage-details-text').text('View usage details'); - } -}); - async function update_usage_details ($el_window) { // Add spinning animation and record start time const startTime = Date.now(); diff --git a/src/gui/src/css/style.css b/src/gui/src/css/style.css index b891892c0..2c5e3b5ec 100644 --- a/src/gui/src/css/style.css +++ b/src/gui/src/css/style.css @@ -4650,6 +4650,31 @@ fieldset[name=number-code] { font-weight: 500; } +.driver-usage-details-content-table thead th.sortable-th { + cursor: pointer; + user-select: none; + transition: background-color 0.15s ease; +} + +.driver-usage-details-content-table thead th.sortable-th:hover { + background-color: #e5e5e5; +} + +.driver-usage-details-content-table thead th .sort-icon { + margin-left: 4px; + display: inline-flex; + vertical-align: middle; +} + +.driver-usage-details-content-table thead th .sort-icon-neutral { + opacity: 0.35; +} + +.driver-usage-details-content-table thead th .sort-icon-asc, +.driver-usage-details-content-table thead th .sort-icon-desc { + opacity: 0.85; +} + .driver-usage-details-content-table td { padding: 7px 5px; border: 1px solid #e0e0e0; @@ -5573,6 +5598,11 @@ fieldset[name=number-code] { margin: 0 auto; } +.dashboard-section-usage { + max-width: 600px; + margin: 0 auto; +} + /* Dashboard Apps */ .dashboard-apps-container { margin-top: 8px; @@ -5693,6 +5723,12 @@ fieldset[name=number-code] { transform: rotate(-45deg) translate(4px, -4px); } +.dashboard-sidebar-separator { + height: 1px; + background: #e0e0e0; + margin: 8px 0; +} + /* Responsive: tablet and below */ @media (max-width: 768px) { .dashboard-sidebar-toggle {