From 9261ac5b61c89a2e565a0933e7d2c083ba0e0788 Mon Sep 17 00:00:00 2001 From: r2dedios Date: Wed, 24 Jun 2026 13:19:11 +0200 Subject: [PATCH 01/26] refactor(console): rename Instances to Nodes across the UI --- .../src/app/AppLayout/SidebarNavigation.tsx | 2 +- .../NodeDetails.tsx} | 21 ++++++++++++---- .../{Servers/Servers.tsx => Nodes/Nodes.tsx} | 16 +++++++------ .../components/NodesTable.tsx} | 23 +++++++++--------- .../components/NodesTableToolbar.tsx} | 24 ++++++++----------- console/src/app/{Servers => Nodes}/types.ts | 4 ++-- console/src/app/index.tsx | 8 +++---- console/src/app/utils/renderUtils.tsx | 5 +++- 8 files changed, 59 insertions(+), 44 deletions(-) rename console/src/app/{ServerDetails/ServerDetails.tsx => NodeDetails/NodeDetails.tsx} (88%) rename console/src/app/{Servers/Servers.tsx => Nodes/Nodes.tsx} (80%) rename console/src/app/{Servers/components/ServersTable.tsx => Nodes/components/NodesTable.tsx} (87%) rename console/src/app/{Servers/components/ServersTableToolbar.tsx => Nodes/components/NodesTableToolbar.tsx} (95%) rename console/src/app/{Servers => Nodes}/types.ts (87%) diff --git a/console/src/app/AppLayout/SidebarNavigation.tsx b/console/src/app/AppLayout/SidebarNavigation.tsx index dac570a..0aa0fc1 100644 --- a/console/src/app/AppLayout/SidebarNavigation.tsx +++ b/console/src/app/AppLayout/SidebarNavigation.tsx @@ -37,7 +37,7 @@ const SidebarNavigation: React.FunctionComponent = () => { Clusters - Instances + Nodes diff --git a/console/src/app/ServerDetails/ServerDetails.tsx b/console/src/app/NodeDetails/NodeDetails.tsx similarity index 88% rename from console/src/app/ServerDetails/ServerDetails.tsx rename to console/src/app/NodeDetails/NodeDetails.tsx index 1b04d0b..4b6bb2a 100644 --- a/console/src/app/ServerDetails/ServerDetails.tsx +++ b/console/src/app/NodeDetails/NodeDetails.tsx @@ -3,6 +3,8 @@ import { renderStatusLabel, ResourceLabel } from '@app/utils/renderUtils'; import { parseScanTimestamp, parseNumberToCurrency } from 'src/app/utils/parseFuncs'; import { useParams } from 'react-router-dom'; import { + Breadcrumb, + BreadcrumbItem, PageSection, Tabs, Tab, @@ -23,6 +25,7 @@ import { } from '@patternfly/react-core'; import { api, InstanceResponseApi, TagResponseApi } from '@api'; import { Link } from 'react-router-dom'; +import { useDocumentTitle } from '@app/utils/useDocumentTitle'; interface LabelGroupOverflowProps { labels: Array; @@ -38,11 +41,12 @@ const LabelGroupOverflow: React.FunctionComponent = ({ ); -const ServerDetails: React.FunctionComponent = () => { +const NodeDetails: React.FunctionComponent = () => { const { instanceID } = useParams(); const [activeTabKey, setActiveTabKey] = React.useState(0); const [instanceData, setInstanceData] = useState(null); const [loading, setLoading] = useState(true); + useDocumentTitle(`${instanceData?.instanceName || instanceID || ''} — ClusterIQ`); useEffect(() => { const fetchData = async () => { try { @@ -75,7 +79,7 @@ const ServerDetails: React.FunctionComponent = () => { - Server details + Node details @@ -131,8 +135,17 @@ const ServerDetails: React.FunctionComponent = () => { {/* Page header */} + + + Home + + + Nodes + + {instanceData?.instanceName || instanceID} + - <ResourceLabel label="Instance" color="#4cb140" /> {instanceData?.instanceName || instanceID} + <ResourceLabel label="Node" color="#4cb140" /> {instanceData?.instanceName || instanceID} {/* Page tabs */} @@ -157,4 +170,4 @@ const ServerDetails: React.FunctionComponent = () => { ); }; -export default ServerDetails; +export default NodeDetails; diff --git a/console/src/app/Servers/Servers.tsx b/console/src/app/Nodes/Nodes.tsx similarity index 80% rename from console/src/app/Servers/Servers.tsx rename to console/src/app/Nodes/Nodes.tsx index 07c8261..1a0526a 100644 --- a/console/src/app/Servers/Servers.tsx +++ b/console/src/app/Nodes/Nodes.tsx @@ -1,9 +1,10 @@ import { PageSection, Panel, Content } from '@patternfly/react-core'; import React from 'react'; -import ServersTableToolbar from './components/ServersTableToolbar'; -import ServersTable from './components/ServersTable'; +import NodesTableToolbar from './components/NodesTableToolbar'; +import NodesTable from './components/NodesTable'; import { parseAsArrayOf, parseAsString, parseAsStringEnum, parseAsBoolean, useQueryStates } from 'nuqs'; import { ResourceStatusApi, ProviderApi } from '@api'; +import { useDocumentTitle } from '@app/utils/useDocumentTitle'; const filterParams = { status: { @@ -15,19 +16,20 @@ const filterParams = { showTerminated: parseAsBoolean.withDefault(false), }; -const Servers: React.FunctionComponent = () => { +const Nodes: React.FunctionComponent = () => { + useDocumentTitle('Nodes — ClusterIQ'); const [{ status, provider, serverName, showTerminated }, setQuery] = useQueryStates(filterParams); return ( - Servers + Nodes - setQuery({ serverName: value })} statusSelection={status} @@ -37,7 +39,7 @@ const Servers: React.FunctionComponent = () => { showTerminated={showTerminated} setShowTerminated={value => setQuery({ showTerminated: value })} /> - { ); }; -export default Servers; +export default Nodes; diff --git a/console/src/app/Servers/components/ServersTable.tsx b/console/src/app/Nodes/components/NodesTable.tsx similarity index 87% rename from console/src/app/Servers/components/ServersTable.tsx rename to console/src/app/Nodes/components/NodesTable.tsx index 8046ed9..7c2b4f5 100644 --- a/console/src/app/Servers/components/ServersTable.tsx +++ b/console/src/app/Nodes/components/NodesTable.tsx @@ -1,18 +1,18 @@ -import { renderStatusLabel } from '@app/utils/renderUtils'; +import { renderStatusLabel, ResourceBadge } from '@app/utils/renderUtils'; import { EmptyState, EmptyStateVariant, EmptyStateBody, Title } from '@patternfly/react-core'; import { ThProps, Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; import React, { useState, useMemo } from 'react'; import { Link } from 'react-router-dom'; -import { ServersTableProps } from '../types'; +import { NodesTableProps } from '../types'; import { InstanceResponseApi } from '@api'; import { TablePagination } from '@app/components/common/TablesPagination'; import { searchItems, filterByStatus, filterByProvider, sortItems } from '@app/utils/tableFilters'; -import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; +import { TableSkeleton } from '@app/components/common/TableSkeleton'; import { ServerIcon } from '@patternfly/react-icons'; import { useInstances } from '@app/hooks/useInstances'; import { useTablePagination } from '@app/hooks/useTablePagination'; -export const ServersTable: React.FunctionComponent = ({ +export const NodesTable: React.FunctionComponent = ({ searchValue, statusSelection, providerSelections, @@ -87,7 +87,7 @@ export const ServersTable: React.FunctionComponent = ({ }; if (isLoading) { - return ; + return ; } if (filtered.length === 0) { @@ -95,7 +95,7 @@ export const ServersTable: React.FunctionComponent = ({ - No instances found + No nodes found } icon={ServerIcon} @@ -104,12 +104,12 @@ export const ServersTable: React.FunctionComponent = ({ {!showTerminated ? ( <> - There are no active instances. + There are no active nodes.
- Toggle 'Show terminated instances' to view all instances. + Toggle 'Show terminated nodes' to view all nodes. ) : ( - 'No instances found.' + 'No nodes match the current filters.' )}
@@ -118,7 +118,7 @@ export const ServersTable: React.FunctionComponent = ({ return ( - +
@@ -133,6 +133,7 @@ export const ServersTable: React.FunctionComponent = ({ {paginatedData.map(instance => (
{columnNames.id}
+ {' '} {instance.instanceId} @@ -157,4 +158,4 @@ export const ServersTable: React.FunctionComponent = ({ ); }; -export default ServersTable; +export default NodesTable; diff --git a/console/src/app/Servers/components/ServersTableToolbar.tsx b/console/src/app/Nodes/components/NodesTableToolbar.tsx similarity index 95% rename from console/src/app/Servers/components/ServersTableToolbar.tsx rename to console/src/app/Nodes/components/NodesTableToolbar.tsx index 9895538..f694268 100644 --- a/console/src/app/Servers/components/ServersTableToolbar.tsx +++ b/console/src/app/Nodes/components/NodesTableToolbar.tsx @@ -17,12 +17,12 @@ import { } from '@patternfly/react-core'; import { FilterIcon } from '@patternfly/react-icons'; import React from 'react'; -import { ServersTableToolbarProps } from '../types'; +import { NodesTableToolbarProps } from '../types'; import { ResourceStatusApi, ProviderApi } from '@api'; import debounce from 'lodash.debounce'; import { usePopperContainer } from '@app/hooks/usePopperContainer'; -export const ServersTableToolbar: React.FunctionComponent = ({ +export const NodesTableToolbar: React.FunctionComponent = ({ searchValue, setSearchValue, setStatusSelection, @@ -40,17 +40,15 @@ export const ServersTableToolbar: React.FunctionComponent debouncedSearch(value)} onClear={() => debouncedSearch('')} /> ); - // Set up name input const [isStatusMenuOpen, setIsStatusMenuOpen] = React.useState(false); const statusToggleRef = React.useRef(null); const statusMenuRef = React.useRef(null); @@ -148,7 +146,6 @@ export const ServersTableToolbar: React.FunctionComponent ); - // Set up provider input const [isProviderMenuOpen, setIsProviderMenuOpen] = React.useState(false); const providerToggleRef = React.useRef(null); const providerMenuRef = React.useRef(null); @@ -280,8 +277,7 @@ export const ServersTableToolbar: React.FunctionComponent ); - // Set up attribute selector - const [activeAttributeMenu, setActiveAttributeMenu] = React.useState<'Servers' | 'Status' | 'Provider'>('Servers'); + const [activeAttributeMenu, setActiveAttributeMenu] = React.useState<'Nodes' | 'Status' | 'Provider'>('Nodes'); const [isAttributeMenuOpen, setIsAttributeMenuOpen] = React.useState(false); const attributeToggleRef = React.useRef(null); const attributeMenuRef = React.useRef(null); @@ -351,13 +347,13 @@ export const ServersTableToolbar: React.FunctionComponent { - setActiveAttributeMenu(itemId?.toString() as 'Servers' | 'Status' | 'Provider'); + setActiveAttributeMenu(itemId?.toString() as 'Nodes' | 'Status' | 'Provider'); setIsAttributeMenuOpen(!isAttributeMenuOpen); }} > - Servers + Nodes Status Provider @@ -396,7 +392,7 @@ export const ServersTableToolbar: React.FunctionComponent setSearchValue('')} deleteLabelGroup={() => setSearchValue('')} categoryName="Name" - showToolbarItem={activeAttributeMenu === 'Servers'} + showToolbarItem={activeAttributeMenu === 'Nodes'} > {searchInput} @@ -422,8 +418,8 @@ export const ServersTableToolbar: React.FunctionComponent setShowTerminated(checked)} /> @@ -433,4 +429,4 @@ export const ServersTableToolbar: React.FunctionComponent void; statusSelection: ResourceStatusApi | null; diff --git a/console/src/app/index.tsx b/console/src/app/index.tsx index 0dd0be4..bebbefc 100644 --- a/console/src/app/index.tsx +++ b/console/src/app/index.tsx @@ -6,10 +6,10 @@ import Overview from './Overview/Overview'; import Clusters from './Clusters/Clusters'; import ClusterDetails from './ClusterDetails/ClusterDetails'; import AccountDetails from './AccountDetails/AccountDetails'; -import ServerDetails from './ServerDetails/ServerDetails'; +import NodeDetails from './NodeDetails/NodeDetails'; import AuditLogs from './Actions/AuditLogs/AuditLogs'; import Scheduler from './Actions/Scheduler/Schedule'; -import Servers from './Servers/Servers'; +import Nodes from './Nodes/Nodes'; import Accounts from './Accounts/Accounts'; import { NuqsAdapter } from 'nuqs/adapters/react'; import { UserProvider } from './Contexts/UserContext'; @@ -36,8 +36,8 @@ const AppRoutes = (): React.ReactElement => ( } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> diff --git a/console/src/app/utils/renderUtils.tsx b/console/src/app/utils/renderUtils.tsx index b9d0b86..2dd9baa 100644 --- a/console/src/app/utils/renderUtils.tsx +++ b/console/src/app/utils/renderUtils.tsx @@ -176,9 +176,12 @@ export function renderProviderIcon(provider: string | null | undefined): React.R } } +const NODE_BADGE = { label: 'N', color: '#4cb140' }; + const RESOURCE_BADGE_MAP: Record = { Cluster: { label: 'C', color: '#0066cc' }, - Instance: { label: 'I', color: '#4cb140' }, + Node: NODE_BADGE, + Instance: NODE_BADGE, Account: { label: 'A', color: '#c9190b' }, }; From dc6b93b7fd6a73564664816f75e620826110cf38 Mon Sep 17 00:00:00 2001 From: r2dedios Date: Wed, 24 Jun 2026 14:14:43 +0200 Subject: [PATCH 02/26] feat(console): add icons to overview cards and fix icon sizing --- console/src/app/Overview/Overview.tsx | 26 ++++++++++++++----- .../app/Overview/components/CardRenderer.tsx | 2 +- .../Overview/components/PartnerDonutChart.tsx | 6 ++++- .../app/Overview/components/TopMetricCard.tsx | 13 +++++++--- console/src/app/Overview/constants.tsx | 6 ++--- 5 files changed, 39 insertions(+), 14 deletions(-) diff --git a/console/src/app/Overview/Overview.tsx b/console/src/app/Overview/Overview.tsx index 8374cce..925d8f7 100644 --- a/console/src/app/Overview/Overview.tsx +++ b/console/src/app/Overview/Overview.tsx @@ -14,7 +14,7 @@ import { EmptyStateFooter, EmptyStateActions, } from '@patternfly/react-core'; -import { CubesIcon } from '@patternfly/react-icons'; +import { CubesIcon, DollarSignIcon, GlobeIcon, UserIcon, HandshakeIcon, HistoryIcon } from '@patternfly/react-icons'; import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; import { generateCards } from './components/CardData'; import { PartnerDonutChart } from './components/PartnerDonutChart'; @@ -23,10 +23,12 @@ import { ProviderApi, TopItemApi } from '@api'; import { renderContent } from './utils/cardRendererUtils.tsx'; import { useDashboardData } from './hooks/useDashboardData'; import { useEventsData } from './hooks/useEventsData'; +import { useDocumentTitle } from '@app/utils/useDocumentTitle'; import { DashboardState } from './types'; import './Overview.css'; const AggregateStatusCards: React.FunctionComponent = () => { + useDocumentTitle('Overview — ClusterIQ'); const { inventoryData, loading, error } = useDashboardData(); const { events, loading: eventsLoading, error: eventsError } = useEventsData(); @@ -119,16 +121,28 @@ const AggregateStatusCards: React.FunctionComponent = () => {
- - - - + } + /> + } /> + } /> + } + />
{/* Row 4: Recent Events */} - {cardData.activityCards[0].title} + + + {cardData.activityCards[0].title} + {eventsLoading ? ( diff --git a/console/src/app/Overview/components/CardRenderer.tsx b/console/src/app/Overview/components/CardRenderer.tsx index 1aaf2b0..94a12c5 100644 --- a/console/src/app/Overview/components/CardRenderer.tsx +++ b/console/src/app/Overview/components/CardRenderer.tsx @@ -15,7 +15,7 @@ export const RenderMultiIcon: React.FunctionComponent<{ content: any[]; totalCou {content.map(({ icon, value, ref }, index) => ( - {icon} + {icon} {ref ? {value} : {value}} {content.length > 1 && index < content.length - 1 && } diff --git a/console/src/app/Overview/components/PartnerDonutChart.tsx b/console/src/app/Overview/components/PartnerDonutChart.tsx index bc2cd35..e01b1e7 100644 --- a/console/src/app/Overview/components/PartnerDonutChart.tsx +++ b/console/src/app/Overview/components/PartnerDonutChart.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Card, CardBody, CardTitle } from '@patternfly/react-core'; import { ChartDonut, ChartThemeColor } from '@patternfly/react-charts/victory'; +import { HandshakeIcon } from '@patternfly/react-icons'; import { TopItemApi } from '@api'; interface PartnerDonutChartProps { @@ -19,7 +20,10 @@ export const PartnerDonutChart: React.FC = ({ data }) => return ( - Clusters by Partner + + + Clusters by Partner + {chartData.length === 0 ? ( No partner data available diff --git a/console/src/app/Overview/components/TopMetricCard.tsx b/console/src/app/Overview/components/TopMetricCard.tsx index 6996199..a9fc6c4 100644 --- a/console/src/app/Overview/components/TopMetricCard.tsx +++ b/console/src/app/Overview/components/TopMetricCard.tsx @@ -6,13 +6,17 @@ interface TopMetricCardProps { title: string; items: TopItemApi[]; formatValue?: (value: number) => string; + icon?: React.ReactNode; } -export const TopMetricCard: React.FC = ({ title, items, formatValue }) => { +export const TopMetricCard: React.FC = ({ title, items, formatValue, icon }) => { if (!items || items.length === 0) { return ( - {title} + + {icon && {icon}} + {title} + No data available @@ -22,7 +26,10 @@ export const TopMetricCard: React.FC = ({ title, items, form return ( - {title} + + {icon && {icon}} + {title} +
{items.map((item, index) => ( diff --git a/console/src/app/Overview/constants.tsx b/console/src/app/Overview/constants.tsx index 14ec30e..6082293 100644 --- a/console/src/app/Overview/constants.tsx +++ b/console/src/app/Overview/constants.tsx @@ -23,9 +23,9 @@ const PATTERNFLY_COLORS = { const CLUSTER_ICON = ; const PROVIDER_ICONS = { - [ProviderApi.AWSProvider]: , - [ProviderApi.GCPProvider]: , - [ProviderApi.AzureProvider]: , + [ProviderApi.AWSProvider]: , + [ProviderApi.GCPProvider]: , + [ProviderApi.AzureProvider]: , } as const; export const STATUSES = { From 80bc6270ec86a93339e3a88d790cd702835b9dce Mon Sep 17 00:00:00 2001 From: r2dedios Date: Wed, 24 Jun 2026 14:15:53 +0200 Subject: [PATCH 03/26] feat(console): add breadcrumbs and document titles to detail pages --- .../AccountDetails/components/AccountHeader.tsx | 12 +++++++++++- .../components/ClusterDetailsOverview.tsx | 17 +++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/console/src/app/AccountDetails/components/AccountHeader.tsx b/console/src/app/AccountDetails/components/AccountHeader.tsx index 3a0a192..a0f78ab 100644 --- a/console/src/app/AccountDetails/components/AccountHeader.tsx +++ b/console/src/app/AccountDetails/components/AccountHeader.tsx @@ -1,12 +1,22 @@ -import { Flex, FlexItem, PageSection, Title } from '@patternfly/react-core'; +import { Breadcrumb, BreadcrumbItem, Flex, FlexItem, PageSection, Title } from '@patternfly/react-core'; import { AccountsHeaderProps } from './types'; import { ResourceLabel } from '@app/utils/renderUtils'; import { AccountDetailsDropdown } from './AccountDetailsDropdown'; import React from 'react'; +import { Link } from 'react-router-dom'; export const AccountsHeader: React.FunctionComponent = ({ accountName, accountId }) => { return ( + + + Home + + + Accounts + + {accountName} + { const { clusterID } = useParams(); @@ -32,6 +35,7 @@ const ClusterDetailsOverview: React.FunctionComponent = () => { const [tags, setTagData] = useState([]); const [cluster, setClusterData] = useState(null); const [loading, setLoading] = useState(true); + useDocumentTitle(`${cluster?.clusterName || clusterID || ''} — ClusterIQ`); useEffect(() => { if (!clusterID) return; @@ -177,6 +181,15 @@ const ClusterDetailsOverview: React.FunctionComponent = () => { return ( + + + Home + + + Clusters + + {cluster?.clusterName || clusterID} + { Details} tabContentId={`tabContent${0}`} /> - Servers} tabContentId={`tabContent${1}`} /> + Nodes} tabContentId={`tabContent${1}`} /> Events} tabContentId={`tabContent${2}`} /> From 6b126c8cfba93e8bb41f44a4bac637332e524639 Mon Sep 17 00:00:00 2001 From: r2dedios Date: Wed, 24 Jun 2026 14:16:13 +0200 Subject: [PATCH 04/26] feat(console): add badges, provider icons, and cost column to tables --- .../app/Accounts/components/AccountsTable.tsx | 27 ++++++++++++++----- .../app/Clusters/components/ClustersTable.tsx | 24 +++++++++-------- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/console/src/app/Accounts/components/AccountsTable.tsx b/console/src/app/Accounts/components/AccountsTable.tsx index eb458d2..17cfb23 100644 --- a/console/src/app/Accounts/components/AccountsTable.tsx +++ b/console/src/app/Accounts/components/AccountsTable.tsx @@ -2,9 +2,11 @@ import { ThProps, Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-tabl import React, { useState, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { AccountResponseApi, ProviderApi } from '@api'; -import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; +import { TableSkeleton } from '@app/components/common/TableSkeleton'; import { TablePagination } from '@app/components/common/TablesPagination'; import { searchItems, filterByProvider, sortItems } from '@app/utils/tableFilters'; +import { ResourceBadge, renderProviderIcon } from '@app/utils/renderUtils'; +import { parseNumberToCurrency, parseScanTimestamp } from '@app/utils/parseFuncs'; import { useAccounts } from '@app/hooks/useAccounts'; import { useTablePagination } from '@app/hooks/useTablePagination'; @@ -23,7 +25,13 @@ export const AccountsTable: React.FunctionComponent<{ result = filterByProvider(result, providerSelections); if (activeSortIndex !== undefined && activeSortDirection) { - const sortFields: (keyof AccountResponseApi)[] = ['accountName', 'provider', 'clusterCount']; + const sortFields: (keyof AccountResponseApi)[] = [ + 'accountName', + 'provider', + 'clusterCount', + 'last15DaysCost', + 'lastScanTimestamp', + ]; result = sortItems(result, sortFields[activeSortIndex], activeSortDirection); } @@ -51,31 +59,38 @@ export const AccountsTable: React.FunctionComponent<{ const columnNames = { name: 'Name', - cloudProvider: 'Cloud Provider', + provider: 'Provider', clusterCount: 'Cluster Count', + cost15d: 'Cost (15d)', + lastScan: 'Last Scan', }; return ( <> {isLoading ? ( - + ) : ( - + + + {paginatedData.map(account => ( - + + + ))} diff --git a/console/src/app/Clusters/components/ClustersTable.tsx b/console/src/app/Clusters/components/ClustersTable.tsx index c73c4b7..c8c92f2 100644 --- a/console/src/app/Clusters/components/ClustersTable.tsx +++ b/console/src/app/Clusters/components/ClustersTable.tsx @@ -1,10 +1,11 @@ -import { renderStatusLabel } from '@app/utils/renderUtils'; +import { renderStatusLabel, ResourceBadge, renderProviderIcon } from '@app/utils/renderUtils'; +import { parseNumberToCurrency } from '@app/utils/parseFuncs'; import { ThProps, Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; import React, { useState, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { ClusterResponseApi } from '@api'; import { ClustersTableProps } from '../types'; -import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; +import { TableSkeleton } from '@app/components/common/TableSkeleton'; import { TablePagination } from '@app/components/common/TablesPagination'; import { searchItems, filterByStatus, filterByProvider, sortItems } from '@app/utils/tableFilters'; import { EmptyState, EmptyStateVariant, EmptyStateBody, Title } from '@patternfly/react-core'; @@ -50,7 +51,7 @@ export const ClustersTable: React.FunctionComponent = ({ 'accountId', 'provider', 'region', - 'instanceCount', + 'last15DaysCost', 'consoleLink', ]; processed = sortItems(processed, sortFields[activeSortIndex], activeSortDirection); @@ -78,9 +79,9 @@ export const ClustersTable: React.FunctionComponent = ({ name: 'Name', status: 'Status', account: 'Account', - cloudProvider: 'Cloud Provider', + provider: 'Provider', region: 'Region', - nodes: 'Nodes', + cost15d: 'Cost (15d)', console: 'Web console', }; @@ -98,7 +99,7 @@ export const ClustersTable: React.FunctionComponent = ({ }); if (isLoading) { - return ; + return ; } if (filtered.length === 0) { @@ -120,7 +121,7 @@ export const ClustersTable: React.FunctionComponent = ({ Toggle 'Show terminated clusters' to view all clusters. ) : ( - 'No clusters found.' + 'No clusters match the current filters.' )} @@ -136,9 +137,9 @@ export const ClustersTable: React.FunctionComponent = ({ - + - + @@ -146,6 +147,7 @@ export const ClustersTable: React.FunctionComponent = ({ {paginatedData.map(cluster => ( @@ -153,9 +155,9 @@ export const ClustersTable: React.FunctionComponent = ({ - + - +
{columnNames.name}{columnNames.cloudProvider}{columnNames.provider} {columnNames.clusterCount}{columnNames.cost15d}{columnNames.lastScan}
+ {' '} {account.accountName} {account.provider}{renderProviderIcon(account.provider)} {account.clusterCount}{parseNumberToCurrency(account.last15DaysCost)}{parseScanTimestamp(account.lastScanTimestamp)}
{columnNames.name} {columnNames.status} {columnNames.account}{columnNames.cloudProvider}{columnNames.provider} {columnNames.region}{columnNames.nodes}{columnNames.cost15d} {columnNames.console}
+ {' '} {cluster.clusterId} {cluster.clusterName} {cluster.accountName} {cluster.provider}{renderProviderIcon(cluster.provider)} {cluster.region}{cluster.instanceCount}{parseNumberToCurrency(cluster.last15DaysCost)} Console From a383b762eaac5b70e78ccec21360a5df6cb31256 Mon Sep 17 00:00:00 2001 From: r2dedios Date: Wed, 24 Jun 2026 14:16:23 +0200 Subject: [PATCH 05/26] feat(console): replace loading spinners with table skeleton loaders --- .../components/AccountClusters.tsx | 4 +-- .../app/Actions/AuditLogs/AuditLogsTable.tsx | 4 +-- .../Scheduler/components/ActionsTable.tsx | 4 +-- .../components/ClusterDetailsEvents.tsx | 4 +-- .../components/ClusterDetailsInstances.tsx | 6 ++-- .../app/components/common/TableSkeleton.tsx | 32 +++++++++++++++++++ 6 files changed, 43 insertions(+), 11 deletions(-) create mode 100644 console/src/app/components/common/TableSkeleton.tsx diff --git a/console/src/app/AccountDetails/components/AccountClusters.tsx b/console/src/app/AccountDetails/components/AccountClusters.tsx index 00ab96e..319eac0 100644 --- a/console/src/app/AccountDetails/components/AccountClusters.tsx +++ b/console/src/app/AccountDetails/components/AccountClusters.tsx @@ -11,7 +11,7 @@ import { EmptyStateVariant, } from '@patternfly/react-core'; import { CubesIcon } from '@patternfly/react-icons'; -import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; +import { TableSkeleton } from '@app/components/common/TableSkeleton'; import { ClustersTable } from './ClustersTable'; import { api, ClusterResponseApi } from '@api'; import { debug } from '@app/utils/debugLogs'; @@ -47,7 +47,7 @@ export const AccountClusters: React.FunctionComponent = () => { }; if (loading) { - return ; + return ; } return ( diff --git a/console/src/app/Actions/AuditLogs/AuditLogsTable.tsx b/console/src/app/Actions/AuditLogs/AuditLogsTable.tsx index 672313c..8f7b653 100644 --- a/console/src/app/Actions/AuditLogs/AuditLogsTable.tsx +++ b/console/src/app/Actions/AuditLogs/AuditLogsTable.tsx @@ -1,4 +1,4 @@ -import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; +import { TableSkeleton } from '@app/components/common/TableSkeleton'; import { ActionOperations, ResultStatus } from '@app/types/types'; import { SystemEventResponseApi } from '@api'; import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; @@ -95,7 +95,7 @@ export const AuditLogsTable: React.FunctionComponent = ({ 'desc' ); - if (isLoading) return ; + if (isLoading) return ; if (totalItems === 0) return ; return ( diff --git a/console/src/app/Actions/Scheduler/components/ActionsTable.tsx b/console/src/app/Actions/Scheduler/components/ActionsTable.tsx index ec55570..685e7f2 100644 --- a/console/src/app/Actions/Scheduler/components/ActionsTable.tsx +++ b/console/src/app/Actions/Scheduler/components/ActionsTable.tsx @@ -10,7 +10,7 @@ import React, { useEffect, useMemo } from 'react'; import { ActionStatus, ActionOperations, ActionTypes } from '@app/types/types'; import { parseScanTimestamp } from '@app/utils/parseFuncs'; import cronstrue from 'cronstrue'; -import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; +import { TableSkeleton } from '@app/components/common/TableSkeleton'; import { TablePagination } from '@app/components/common/TablesPagination'; import { ActionsColumn } from '@patternfly/react-table'; import { rowActions } from './ActionsKebabMenu'; @@ -82,7 +82,7 @@ export const ScheduleActionsTable: React.FunctionComponent<{ return ( <> {isLoading ? ( - + ) : ( diff --git a/console/src/app/ClusterDetails/components/ClusterDetailsEvents.tsx b/console/src/app/ClusterDetails/components/ClusterDetailsEvents.tsx index 1cd9bda..c023c77 100644 --- a/console/src/app/ClusterDetails/components/ClusterDetailsEvents.tsx +++ b/console/src/app/ClusterDetails/components/ClusterDetailsEvents.tsx @@ -1,4 +1,4 @@ -import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; +import { TableSkeleton } from '@app/components/common/TableSkeleton'; import { ResultStatus } from '@app/types/types'; import { api, SystemEventResponseApi } from '@api'; import { ThProps, Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; @@ -106,7 +106,7 @@ export const ClusterDetailsEvents: React.FunctionComponent = () => { }; const { sortedData, getSortParams } = useTableSort(data, getSortableRowValues, 5, 'desc'); - if (loading) return ; + if (loading) return ; if (sortedData.length === 0) return ; return ; }; diff --git a/console/src/app/ClusterDetails/components/ClusterDetailsInstances.tsx b/console/src/app/ClusterDetails/components/ClusterDetailsInstances.tsx index 02a7787..3c6ef30 100644 --- a/console/src/app/ClusterDetails/components/ClusterDetailsInstances.tsx +++ b/console/src/app/ClusterDetails/components/ClusterDetailsInstances.tsx @@ -1,4 +1,4 @@ -import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; +import { TableSkeleton } from '@app/components/common/TableSkeleton'; import { renderStatusLabel } from '@app/utils/renderUtils'; import { sortItems } from '@app/utils/tableFilters'; import { api, InstanceResponseApi } from '@api'; @@ -33,7 +33,7 @@ const ClusterDetailsInstances: React.FunctionComponent = () => { }, [clusterID]); if (!clusterID) { - return ; + return ; } console.log('Rendered with data:', data); @@ -66,7 +66,7 @@ const ClusterDetailsInstances: React.FunctionComponent = () => { return ( {loading ? ( - + ) : (
diff --git a/console/src/app/components/common/TableSkeleton.tsx b/console/src/app/components/common/TableSkeleton.tsx new file mode 100644 index 0000000..bb166c2 --- /dev/null +++ b/console/src/app/components/common/TableSkeleton.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Skeleton } from '@patternfly/react-core'; + +interface TableSkeletonProps { + rows?: number; + columns?: number; +} + +export const TableSkeleton: React.FunctionComponent = ({ rows = 5, columns = 4 }) => ( +
+
+ {Array.from({ length: columns }, (_, i) => ( + + ))} +
+ {Array.from({ length: rows }, (_, rowIndex) => ( +
+ {Array.from({ length: columns }, (_, colIndex) => ( + + ))} +
+ ))} +
+); From 033dd171bc2c85eebcc36ff274d52a6f978dca17 Mon Sep 17 00:00:00 2001 From: r2dedios Date: Wed, 24 Jun 2026 14:17:04 +0200 Subject: [PATCH 06/26] feat(console): unify timestamps with relative time format --- console/src/app/Overview/components/CardData.tsx | 6 ++---- console/src/app/utils/parseFuncs.tsx | 7 +++++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/console/src/app/Overview/components/CardData.tsx b/console/src/app/Overview/components/CardData.tsx index 53ee81e..10ce5bc 100644 --- a/console/src/app/Overview/components/CardData.tsx +++ b/console/src/app/Overview/components/CardData.tsx @@ -3,15 +3,13 @@ import { CardDefinition, CardLayout, DashboardState } from '../types'; import { CLOUD_PROVIDERS, STATUSES, TOTAL_COUNT_ICONS } from '../constants'; import { SystemEventResponseApi } from '@api'; import { ActivityTable } from './ActivityTable'; +import { parseScanTimestamp } from '@app/utils/parseFuncs'; export const generateCards = ( state: DashboardState, events: SystemEventResponseApi[] = [] ): Record => { - const isValidTimestamp = state.lastScanTimestamp && state.lastScanTimestamp !== '0001-01-01T00:00:00Z'; - const scannerContent = isValidTimestamp - ? `${new Date(state.lastScanTimestamp!).toLocaleString()}` - : 'No scan data available'; + const scannerContent = parseScanTimestamp(state.lastScanTimestamp); const totalAccounts = Object.values(state.accountsByProvider).reduce((sum, count) => sum + count, 0); const totalClustersByProvider = diff --git a/console/src/app/utils/parseFuncs.tsx b/console/src/app/utils/parseFuncs.tsx index 6b6ecb3..00c5dc4 100644 --- a/console/src/app/utils/parseFuncs.tsx +++ b/console/src/app/utils/parseFuncs.tsx @@ -1,9 +1,12 @@ -import { parseISO, format } from 'date-fns'; +import { parseISO, format, formatDistanceToNow } from 'date-fns'; import { createParser } from 'nuqs'; export function parseScanTimestamp(ts: string | undefined) { if (!ts || ts.startsWith('0001-01-01')) return 'Never'; - return format(parseISO(ts), 'HH:mm:ss - dd/MM/yyyy'); + const date = parseISO(ts); + const absolute = format(date, 'HH:mm:ss - dd/MM/yyyy'); + const relative = formatDistanceToNow(date, { addSuffix: true }); + return `${absolute} (${relative})`; } export function parseNumberToCurrency(value: number | undefined) { From 8a3f346f46b73cb5b8cf49895b8b8d3e4fd5d97c Mon Sep 17 00:00:00 2001 From: r2dedios Date: Wed, 24 Jun 2026 14:17:13 +0200 Subject: [PATCH 07/26] feat(console): add dynamic browser tab titles to all pages --- console/src/app/AccountDetails/AccountDetails.tsx | 2 ++ console/src/app/Accounts/Accounts.tsx | 2 ++ console/src/app/Actions/AuditLogs/AuditLogs.tsx | 2 ++ console/src/app/Actions/Scheduler/Schedule.tsx | 2 ++ console/src/app/Clusters/Clusters.tsx | 2 ++ 5 files changed, 10 insertions(+) diff --git a/console/src/app/AccountDetails/AccountDetails.tsx b/console/src/app/AccountDetails/AccountDetails.tsx index fc46e6d..4e60866 100644 --- a/console/src/app/AccountDetails/AccountDetails.tsx +++ b/console/src/app/AccountDetails/AccountDetails.tsx @@ -6,10 +6,12 @@ import AccountsTabs from './components/AccountTabs'; import { AccountDetailsContent } from './components/AccountDetailsContent'; import { debug } from '@app/utils/debugLogs'; import { AccountClusters } from './components/AccountClusters'; +import { useDocumentTitle } from '@app/utils/useDocumentTitle'; const AccountDetails: React.FunctionComponent = () => { const { accountId } = useParams() as { accountId: string }; const [accountData, setAccountData] = useState(null); + useDocumentTitle(`${accountData?.accountName || accountId} — ClusterIQ`); const [loading, setLoading] = useState(true); useEffect(() => { const fetchData = async () => { diff --git a/console/src/app/Accounts/Accounts.tsx b/console/src/app/Accounts/Accounts.tsx index b0850b3..a0d40f8 100644 --- a/console/src/app/Accounts/Accounts.tsx +++ b/console/src/app/Accounts/Accounts.tsx @@ -2,6 +2,7 @@ import { PageSection, Panel, Content } from '@patternfly/react-core'; import React from 'react'; import AccountsToolbar from './components/AccountsToolbar'; import AccountsTable from './components/AccountsTable'; +import { useDocumentTitle } from '@app/utils/useDocumentTitle'; import { parseAsArrayOf, parseAsString, parseAsStringEnum, useQueryStates } from 'nuqs'; import { ProviderApi } from '@api'; @@ -11,6 +12,7 @@ const filterParams = { }; const Accounts: React.FunctionComponent = () => { + useDocumentTitle('Accounts — ClusterIQ'); const [{ provider, accountName }, setQuery] = useQueryStates(filterParams); return ( diff --git a/console/src/app/Actions/AuditLogs/AuditLogs.tsx b/console/src/app/Actions/AuditLogs/AuditLogs.tsx index 9c390f6..9873004 100644 --- a/console/src/app/Actions/AuditLogs/AuditLogs.tsx +++ b/console/src/app/Actions/AuditLogs/AuditLogs.tsx @@ -5,6 +5,7 @@ import { parseAsArrayOf, parseAsString, parseAsStringEnum, useQueryStates } from import { ActionOperations, ResultStatus } from '@app/types/types.tsx'; import { ProviderApi } from '@api'; import { AuditLogsTable } from './AuditLogsTable.tsx'; +import { useDocumentTitle } from '@app/utils/useDocumentTitle'; const filterParams = { accountName: parseAsString.withDefault(''), @@ -15,6 +16,7 @@ const filterParams = { }; const AuditLogs: React.FunctionComponent = () => { + useDocumentTitle('Audit Logs — ClusterIQ'); const [{ accountName, action, provider, result, requester }, setQuery] = useQueryStates(filterParams); return ( diff --git a/console/src/app/Actions/Scheduler/Schedule.tsx b/console/src/app/Actions/Scheduler/Schedule.tsx index 31a7233..7230c02 100644 --- a/console/src/app/Actions/Scheduler/Schedule.tsx +++ b/console/src/app/Actions/Scheduler/Schedule.tsx @@ -7,6 +7,7 @@ import { ActionOperations, ActionTypes, ActionStatus } from '@app/types/types'; import { parseAsArrayOf, parseAsString, parseAsStringEnum, useQueryStates } from 'nuqs'; import { parseAsBooleanNullable } from '@app/utils/parseFuncs'; +import { useDocumentTitle } from '@app/utils/useDocumentTitle'; // Nullable boolean: "true" -> true, "false" -> false, missing/other -> null @@ -19,6 +20,7 @@ const filterParams = { }; const Scheduler: React.FunctionComponent = () => { + useDocumentTitle('Scheduler — ClusterIQ'); const [{ accountId, action, type, status, enabled }, setQuery] = useQueryStates(filterParams); const [isModalOpen, setIsModalOpen] = React.useState(false); const [reloadFlag, setReloadFlag] = React.useState(0); diff --git a/console/src/app/Clusters/Clusters.tsx b/console/src/app/Clusters/Clusters.tsx index 99a8a14..61e0973 100644 --- a/console/src/app/Clusters/Clusters.tsx +++ b/console/src/app/Clusters/Clusters.tsx @@ -4,6 +4,7 @@ import ClustersTable from './components/ClustersTable'; import ClustersTableToolbar from './components/ClustersTableToolbar'; import { parseAsArrayOf, parseAsString, parseAsStringEnum, parseAsBoolean, useQueryStates } from 'nuqs'; import { ResourceStatusApi, ProviderApi } from '@api'; +import { useDocumentTitle } from '@app/utils/useDocumentTitle'; const filterParams = { status: { @@ -17,6 +18,7 @@ const filterParams = { }; const Clusters: React.FunctionComponent = () => { + useDocumentTitle('Clusters — ClusterIQ'); const [{ status, provider, clusterName, accountName, showTerminated }, setQuery] = useQueryStates(filterParams); return ( From 3921521b2f62b10cb28724427e1e767ef4cb1b17 Mon Sep 17 00:00:00 2001 From: r2dedios Date: Wed, 24 Jun 2026 17:31:20 +0200 Subject: [PATCH 08/26] fix(scanner): handle both date formats from AWS Cost Explorer response --- internal/stocker/aws_billing_stocker.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/stocker/aws_billing_stocker.go b/internal/stocker/aws_billing_stocker.go index 13156bd..813a8c9 100644 --- a/internal/stocker/aws_billing_stocker.go +++ b/internal/stocker/aws_billing_stocker.go @@ -133,8 +133,12 @@ func (s *AWSBillingStocker) getInstanceExpenses(instance *inventory.Instance) er return err } - // Getting Expense Date as Time + // AWS Cost Explorer DateInterval uses pattern (\d{4}-\d{2}-\d{2})(T\d{2}:\d{2}:\d{2}Z)? + // DAILY granularity typically returns "YYYY-MM-DD" but may include "T00:00:00Z". expenseDate, err := time.Parse(time.RFC3339, *resultByTime.TimePeriod.Start) + if err != nil { + expenseDate, err = time.Parse("2006-01-02", *resultByTime.TimePeriod.Start) + } if err != nil { s.logger.Error("Error parsing start date", zap.String("account", s.Account.AccountName), From ea0fde258d9e50ac08f52356a61101ee3a5f5de3 Mon Sep 17 00:00:00 2001 From: r2dedios Date: Wed, 24 Jun 2026 17:33:45 +0200 Subject: [PATCH 09/26] fix(scanner): replace incorrect comments on expense date variables --- internal/stocker/aws_billing_stocker.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/stocker/aws_billing_stocker.go b/internal/stocker/aws_billing_stocker.go index 813a8c9..291750b 100644 --- a/internal/stocker/aws_billing_stocker.go +++ b/internal/stocker/aws_billing_stocker.go @@ -80,10 +80,8 @@ func (s *AWSBillingStocker) MakeStock() error { // getInstanceExpenses gets from the AWS CostExplorer API the expenses of a given Instance. func (s *AWSBillingStocker) getInstanceExpenses(instance *inventory.Instance) error { - // Logic for Setting the period to fetch the Expenses within - // End date is equivalent to today's date + // 14-day rolling window: the maximum range AWS Cost Explorer supports at daily resource-level granularity. startDate := time.Now().AddDate(0, 0, -14).Format("2006-01-02") - // Start date is equivalent to today's date endDate := time.Now().Format("2006-01-02") s.logger.Debug("Getting expenses for instance", From 7e19ac267ef1e56c72d50715c6fce9e58d453514 Mon Sep 17 00:00:00 2001 From: r2dedios Date: Wed, 24 Jun 2026 17:37:00 +0200 Subject: [PATCH 10/26] fix(scanner): remove dead nil check on NewExpense return value --- internal/stocker/aws_billing_stocker.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/stocker/aws_billing_stocker.go b/internal/stocker/aws_billing_stocker.go index 291750b..2554f44 100644 --- a/internal/stocker/aws_billing_stocker.go +++ b/internal/stocker/aws_billing_stocker.go @@ -145,11 +145,9 @@ func (s *AWSBillingStocker) getInstanceExpenses(instance *inventory.Instance) er return err } + // NewExpense always returns a valid pointer; negative amounts are clamped to 0.0 + // by the constructor. AWS Cost Explorer does not return negative costs for instances. expense := inventory.NewExpense(instance.InstanceID, amount, expenseDate) - if expense == nil { - s.logger.Error("error creating expense during billing scan. Check if amount is lower than 0.0") - continue - } if err := instance.AddExpense(expense); err != nil { s.logger.Error("error when adding an expense to an instance", zap.String("instance_id", instance.InstanceID), From a20b770342b60651a042da5c436b23c6e876957b Mon Sep 17 00:00:00 2001 From: r2dedios Date: Wed, 24 Jun 2026 17:38:54 +0200 Subject: [PATCH 11/26] fix(scanner): use UTC for expense date boundaries in Cost Explorer queries --- internal/stocker/aws_billing_stocker.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/stocker/aws_billing_stocker.go b/internal/stocker/aws_billing_stocker.go index 2554f44..cc8c197 100644 --- a/internal/stocker/aws_billing_stocker.go +++ b/internal/stocker/aws_billing_stocker.go @@ -81,8 +81,10 @@ func (s *AWSBillingStocker) MakeStock() error { // getInstanceExpenses gets from the AWS CostExplorer API the expenses of a given Instance. func (s *AWSBillingStocker) getInstanceExpenses(instance *inventory.Instance) error { // 14-day rolling window: the maximum range AWS Cost Explorer supports at daily resource-level granularity. - startDate := time.Now().AddDate(0, 0, -14).Format("2006-01-02") - endDate := time.Now().Format("2006-01-02") + // UTC is required because AWS Cost Explorer uses UTC internally for date boundaries. + now := time.Now().UTC() + startDate := now.AddDate(0, 0, -14).Format("2006-01-02") + endDate := now.Format("2006-01-02") s.logger.Debug("Getting expenses for instance", zap.String("account", s.Account.AccountName), From b83c426078299c41c74b7e7ea65a544e9740a93e Mon Sep 17 00:00:00 2001 From: r2dedios Date: Wed, 24 Jun 2026 17:41:48 +0200 Subject: [PATCH 12/26] fix(db): run partition creation daily instead of weekly --- db/sql/cron.sql | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/db/sql/cron.sql b/db/sql/cron.sql index 74ec6bb..2665b2b 100644 --- a/db/sql/cron.sql +++ b/db/sql/cron.sql @@ -10,18 +10,18 @@ SELECT cron.schedule_in_database( 'clusteriq' ); --- pg_cron task for creating a new monthly partition for 'Expenses' table every Sunday +-- pg_cron task for creating a new monthly partition for 'Expenses' table daily SELECT cron.schedule_in_database( 'expenses_partitioning', - '0 0 * * 6', + '0 0 * * *', $$SELECT create_next_month_expenses_partition();$$, 'clusteriq' ); --- pg_cron task for creating a new monthly partition for 'audit_logs' table every Sunday +-- pg_cron task for creating a new monthly partition for 'audit_logs' table daily SELECT cron.schedule_in_database( 'events_partitioning', - '0 0 * * 6', + '0 0 * * *', $$SELECT create_next_month_events_partition();$$, 'clusteriq' ); From 7a609eb2b847d28eea54e7b8159702d05b76c457 Mon Sep 17 00:00:00 2001 From: r2dedios Date: Thu, 25 Jun 2026 09:36:17 +0200 Subject: [PATCH 13/26] feat(api): add daily-costs endpoint for account cost evolution --- cmd/api/router.go | 1 + internal/api/handlers/account_handler.go | 43 +++++++++++++++++++++ internal/models/db/expense.go | 6 +++ internal/models/dto/account_dto.go | 6 +++ internal/repositories/account_repository.go | 26 +++++++++++++ internal/services/account_service.go | 10 +++++ 6 files changed, 92 insertions(+) diff --git a/cmd/api/router.go b/cmd/api/router.go index daa810b..f1dea47 100644 --- a/cmd/api/router.go +++ b/cmd/api/router.go @@ -52,6 +52,7 @@ func setupAccountRoutes(group *gin.RouterGroup, handler *handlers.AccountHandler accounts.GET("/:id", handler.GetByID) accounts.GET("/:id/clusters", handler.GetAccountClustersByID) accounts.GET("/:id/expense_update", handler.GetExpensesUpdateInstances) + accounts.GET("/:id/daily-costs", handler.GetDailyCosts) accounts.PATCH("/:id", handler.Update) accounts.DELETE("/:id", handler.Delete) } diff --git a/internal/api/handlers/account_handler.go b/internal/api/handlers/account_handler.go index 63ed6c7..2a3daeb 100644 --- a/internal/api/handlers/account_handler.go +++ b/internal/api/handlers/account_handler.go @@ -208,6 +208,49 @@ func (h *AccountHandler) GetExpensesUpdateInstances(c *gin.Context) { c.JSON(http.StatusOK, response) } +// GetDailyCosts returns the daily cost evolution for an account (last 6 months). +// +// @Summary Get daily costs for an account +// @Description Return the aggregated daily costs for the specified account over the last 6 months. +// @Tags Accounts +// @Accept json +// @Produce json +// @Param id path string true "Account ID" +// @Success 200 {object} responsetypes.ListResponse[dto.DailyCostDTOResponse] +// @Failure 404 {object} responsetypes.GenericErrorResponse +// @Failure 500 {object} responsetypes.GenericErrorResponse +// @Router /accounts/{id}/daily-costs [get] +func (h *AccountHandler) GetDailyCosts(c *gin.Context) { + accountID := c.Param("id") + + costs, err := h.service.GetDailyCosts(c.Request.Context(), accountID) + if err != nil { + h.logger.Error("error getting daily costs", zap.String("account_id", accountID), zap.Error(err)) + if errors.Is(err, repositories.ErrNotFound) { + c.JSON(http.StatusNotFound, responsetypes.GenericErrorResponse{ + Message: "Account not found", + }) + return + } + + c.JSON(http.StatusInternalServerError, responsetypes.GenericErrorResponse{ + Message: "Failed to retrieve daily costs", + }) + return + } + + items := make([]dto.DailyCostDTOResponse, len(costs)) + for i, cost := range costs { + items[i] = dto.DailyCostDTOResponse{ + Date: cost.Date.Format("2006-01-02"), + Amount: cost.Amount, + } + } + + response := responsetypes.NewListResponse(items, len(items)) + c.JSON(http.StatusOK, response) +} + // Create creates one or more accounts. // // @Summary Create accounts diff --git a/internal/models/db/expense.go b/internal/models/db/expense.go index f87380d..40a01b3 100644 --- a/internal/models/db/expense.go +++ b/internal/models/db/expense.go @@ -11,3 +11,9 @@ type ExpenseDBResponse struct { Amount float64 `db:"amount"` Date time.Time `db:"date"` } + +// DailyCostDBResponse represents a single day's aggregated cost for an account. +type DailyCostDBResponse struct { + Date time.Time `db:"date"` + Amount float64 `db:"amount"` +} diff --git a/internal/models/dto/account_dto.go b/internal/models/dto/account_dto.go index 9d254b9..9afca22 100644 --- a/internal/models/dto/account_dto.go +++ b/internal/models/dto/account_dto.go @@ -70,6 +70,12 @@ type AccountDTOResponse struct { CurrentMonthSoFarCost float64 `json:"currentMonthSoFarCost"` } // @name AccountResponse +// DailyCostDTOResponse represents a single day's aggregated cost. +type DailyCostDTOResponse struct { + Date string `json:"date"` + Amount float64 `json:"amount"` +} // @name DailyCostResponse + // AccountPatchRequest represents mutable fields for partial account updates. // Only fields present in the request will be updated (using pointers to distinguish null from empty). type AccountPatchRequest struct { diff --git a/internal/repositories/account_repository.go b/internal/repositories/account_repository.go index f832ecb..953b1fc 100644 --- a/internal/repositories/account_repository.go +++ b/internal/repositories/account_repository.go @@ -53,6 +53,7 @@ type AccountRepository interface { GetExpenseUpdateInstances(ctx context.Context, accountID string) ([]db.InstancePendingExpenseDB, error) GetScannerTimestamp(ctx context.Context) (time.Time, error) GetCostPerAccount(ctx context.Context) ([]inventory.AccountCost, error) + GetDailyCosts(ctx context.Context, accountID string) ([]db.DailyCostDBResponse, error) CreateAccount(ctx context.Context, accounts []inventory.Account) error UpdateAccount(ctx context.Context, accountID string, patch dto.AccountPatchRequest) error DeleteAccount(ctx context.Context, accountID string) error @@ -194,6 +195,31 @@ func (r *accountRepositoryImpl) GetCostPerAccount(ctx context.Context) ([]invent return costs, nil } +// GetDailyCosts returns the aggregated daily costs for an account over the last 6 months. +func (r *accountRepositoryImpl) GetDailyCosts(ctx context.Context, accountID string) ([]db.DailyCostDBResponse, error) { + if _, err := r.GetAccountByID(ctx, accountID); err != nil { + return nil, err + } + + var costs []db.DailyCostDBResponse + query := ` + SELECT e.date, SUM(e.amount) AS amount + FROM expenses e + JOIN instances i ON e.instance_id = i.id + JOIN clusters c ON i.cluster_id = c.id + JOIN accounts a ON c.account_id = a.id + WHERE a.account_id = $1 + AND e.date >= CURRENT_DATE - INTERVAL '6 months' + GROUP BY e.date + ORDER BY e.date ASC` + + if err := r.db.QuerySelectContext(ctx, &costs, query, accountID); err != nil { + return nil, fmt.Errorf("failed to get daily costs for account %s: %w", accountID, err) + } + + return costs, nil +} + // Create inserts multiple accounts into the database in a transaction. // // Parameters: diff --git a/internal/services/account_service.go b/internal/services/account_service.go index cb38f38..d6e3c2a 100644 --- a/internal/services/account_service.go +++ b/internal/services/account_service.go @@ -17,6 +17,7 @@ type AccountService interface { GetByID(ctx context.Context, accountID string) (db.AccountDBResponse, error) GetAccountClustersByID(ctx context.Context, accountID string) ([]db.ClusterDBResponse, error) GetExpenseUpdateInstances(ctx context.Context, accountID string) ([]db.InstancePendingExpenseDB, error) + GetDailyCosts(ctx context.Context, accountID string) ([]db.DailyCostDBResponse, error) Create(ctx context.Context, accounts []inventory.Account) error Update(ctx context.Context, accountID string, patch dto.AccountPatchRequest) error Delete(ctx context.Context, accountID string) error @@ -65,6 +66,15 @@ func (s *accountServiceImpl) GetExpenseUpdateInstances(ctx context.Context, acco return instances, nil } +// GetDailyCosts retrieves the aggregated daily costs for an account over the last 6 months. +func (s *accountServiceImpl) GetDailyCosts(ctx context.Context, accountID string) ([]db.DailyCostDBResponse, error) { + costs, err := s.repo.GetDailyCosts(ctx, accountID) + if err != nil { + return costs, fmt.Errorf("get daily costs for account %s: %w", accountID, err) + } + return costs, nil +} + // Create creates one or more new accounts. func (s *accountServiceImpl) Create(ctx context.Context, accounts []inventory.Account) error { if err := s.repo.CreateAccount(ctx, accounts); err != nil { From 84c1abdfab9b97397b2474088a257ccf351e61f4 Mon Sep 17 00:00:00 2001 From: r2dedios Date: Thu, 25 Jun 2026 09:37:24 +0200 Subject: [PATCH 14/26] feat(console): add cost evolution charts to account details page --- console/src/api/Accounts.ts | 17 ++ console/src/api/data-contracts.ts | 10 + .../src/app/AccountDetails/AccountDetails.tsx | 2 + .../components/AccountCostChart.tsx | 218 ++++++++++++++++++ .../AccountDetails/components/AccountTabs.tsx | 10 +- .../app/AccountDetails/components/types.ts | 1 + 6 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 console/src/app/AccountDetails/components/AccountCostChart.tsx diff --git a/console/src/api/Accounts.ts b/console/src/api/Accounts.ts index 4473b55..1876952 100644 --- a/console/src/api/Accounts.ts +++ b/console/src/api/Accounts.ts @@ -15,6 +15,7 @@ import { AccountRequestApi, AccountResponseApi, ClusterListResponseApi, + DailyCostListResponseApi, GenericErrorResponseApi, InstanceListResponseApi, PostResponseApi, @@ -157,4 +158,20 @@ export class Accounts { format: 'json', ...params, }); + /** + * @description Return the aggregated daily costs for the specified account over the last 6 months. + * + * @tags Accounts + * @name DailyCostsList + * @summary Get daily costs for an account + * @request GET:/accounts/{id}/daily-costs + */ + dailyCostsList = (id: string, params: RequestParams = {}) => + this.http.request({ + path: `/accounts/${id}/daily-costs`, + method: 'GET', + type: ContentType.Json, + format: 'json', + ...params, + }); } diff --git a/console/src/api/data-contracts.ts b/console/src/api/data-contracts.ts index b58f35c..51d6ed4 100644 --- a/console/src/api/data-contracts.ts +++ b/console/src/api/data-contracts.ts @@ -343,3 +343,13 @@ export interface TagResponseApi { key?: string; value?: string; } + +export interface DailyCostApi { + date?: string; + amount?: number; +} + +export interface DailyCostListResponseApi { + count?: number; + items?: DailyCostApi[]; +} diff --git a/console/src/app/AccountDetails/AccountDetails.tsx b/console/src/app/AccountDetails/AccountDetails.tsx index 4e60866..23951ef 100644 --- a/console/src/app/AccountDetails/AccountDetails.tsx +++ b/console/src/app/AccountDetails/AccountDetails.tsx @@ -4,6 +4,7 @@ import { api, AccountResponseApi } from '@api'; import AccountsHeader from './components/AccountHeader'; import AccountsTabs from './components/AccountTabs'; import { AccountDetailsContent } from './components/AccountDetailsContent'; +import { AccountCostChart } from './components/AccountCostChart'; import { debug } from '@app/utils/debugLogs'; import { AccountClusters } from './components/AccountClusters'; import { useDocumentTitle } from '@app/utils/useDocumentTitle'; @@ -36,6 +37,7 @@ const AccountDetails: React.FunctionComponent = () => { } clustersTabContent={} + costsTabContent={} /> ); diff --git a/console/src/app/AccountDetails/components/AccountCostChart.tsx b/console/src/app/AccountDetails/components/AccountCostChart.tsx new file mode 100644 index 0000000..53bc6eb --- /dev/null +++ b/console/src/app/AccountDetails/components/AccountCostChart.tsx @@ -0,0 +1,218 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Card, CardBody, CardTitle, Grid, GridItem, Skeleton } from '@patternfly/react-core'; +import { + Chart, + ChartArea, + ChartAxis, + ChartLine, + ChartThemeColor, + ChartVoronoiContainer, +} from '@patternfly/react-charts/victory'; +import { api, ClusterResponseApi, DailyCostApi } from '@api'; + +interface AccountCostChartProps { + accountId: string; +} + +const axisTextStyle = { fill: 'var(--pf-t--global--text--color--regular)' }; + +const tickFormatDate = (t: Date) => { + const month = t.toLocaleString('default', { month: 'short' }); + const day = t.getDate(); + return `${month} ${day}`; +}; + +const formatFullDate = (d: Date) => + `${d.getDate().toString().padStart(2, '0')}/${(d.getMonth() + 1).toString().padStart(2, '0')}/${d.getFullYear()}`; + +const xAxisStyle = { tickLabels: { ...axisTextStyle, angle: -35, textAnchor: 'end' as const, fontSize: 11 } }; +const yAxisStyle = { tickLabels: { ...axisTextStyle, fontSize: 11 } }; + +const buildDateRange = (data: { x: Date }[]): string => { + if (data.length === 0) return 'Last 6 months'; + const first = data[0].x; + const last = data[data.length - 1].x; + return `${formatFullDate(first)} — ${formatFullDate(last)}`; +}; + +export const AccountCostChart: React.FC = ({ accountId }) => { + const [dailyCosts, setDailyCosts] = useState([]); + const [clusters, setClusters] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchData = async () => { + try { + const [costsRes, clustersRes] = await Promise.all([ + api.accounts.dailyCostsList(accountId), + api.accounts.clustersList(accountId), + ]); + setDailyCosts(costsRes.data.items || []); + setClusters(clustersRes.data.items || []); + } catch (error) { + console.error('Error fetching cost evolution data:', error); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [accountId]); + + const dailyChartData = useMemo( + () => + dailyCosts.map(item => ({ + x: new Date(item.date || ''), + y: item.amount ?? 0, + })), + [dailyCosts] + ); + + const cumulativeChartData = useMemo(() => { + let cumulative = 0; + return dailyChartData.map(d => { + cumulative += d.y; + return { x: d.x, y: cumulative }; + }); + }, [dailyChartData]); + + const clusterCountData = useMemo(() => { + if (dailyChartData.length === 0 || clusters.length === 0) return []; + + const sortedCreationDates = clusters + .map(c => new Date(c.createdAt || '')) + .filter(d => !isNaN(d.getTime())) + .sort((a, b) => a.getTime() - b.getTime()); + + return dailyChartData.map(d => { + const count = sortedCreationDates.filter(cd => cd <= d.x).length; + return { x: d.x, y: count }; + }); + }, [dailyChartData, clusters]); + + const dateRange = useMemo(() => buildDateRange(dailyChartData), [dailyChartData]); + + if (loading) { + return ( + + {[1, 2, 3].map(i => ( + + + Loading... + + + + + + ))} + + ); + } + + const maxDaily = Math.max(...dailyChartData.map(d => d.y), 0.01); + const maxCumulative = Math.max(...cumulativeChartData.map(d => d.y), 0.01); + const maxClusters = Math.max(...(clusterCountData.length > 0 ? clusterCountData.map(d => d.y) : [1])); + + return ( + + + + Daily Cost ({dateRange}) + + {dailyChartData.length === 0 ? ( + No cost data available + ) : ( +
+ + `${formatFullDate(datum.x)}: $${datum.y.toFixed(2)}` + } + constrainToVisibleArea + /> + } + > + + `$${t.toFixed(2)}`} style={yAxisStyle} /> + + +
+ )} +
+
+
+ + + + Cumulative Cost ({dateRange}) + + {cumulativeChartData.length === 0 ? ( + No cost data available + ) : ( +
+ + `${formatFullDate(datum.x)}: $${datum.y.toFixed(2)}` + } + constrainToVisibleArea + /> + } + > + + `$${t.toFixed(0)}`} style={yAxisStyle} /> + + +
+ )} +
+
+
+ + + + Cluster Count ({dateRange}) + + {clusterCountData.length === 0 ? ( + No cluster data available + ) : ( +
+ + `${formatFullDate(datum.x)}: ${datum.y} clusters` + } + constrainToVisibleArea + /> + } + > + + `${Math.round(t)}`} style={yAxisStyle} /> + + +
+ )} +
+
+
+
+ ); +}; diff --git a/console/src/app/AccountDetails/components/AccountTabs.tsx b/console/src/app/AccountDetails/components/AccountTabs.tsx index e81d39e..db2bacb 100644 --- a/console/src/app/AccountDetails/components/AccountTabs.tsx +++ b/console/src/app/AccountDetails/components/AccountTabs.tsx @@ -2,7 +2,11 @@ import { PageSection, Tab, TabContent, TabContentBody, Tabs, TabTitleText } from import React from 'react'; import { AccountsTabsProps } from './types'; -export const AccountsTabs: React.FunctionComponent = ({ detailsTabContent, clustersTabContent }) => { +export const AccountsTabs: React.FunctionComponent = ({ + detailsTabContent, + clustersTabContent, + costsTabContent, +}) => { const [activeTabKey, setActiveTabKey] = React.useState(0); const handleTabClick = (_event: React.MouseEvent, eventKey: string | number) => { setActiveTabKey(eventKey as number); @@ -14,6 +18,7 @@ export const AccountsTabs: React.FunctionComponent = ({ detai Details} tabContentId={`tabContent${0}`} /> Clusters} tabContentId={`tabContent${1}`} /> + Cost Evolution} tabContentId={`tabContent${2}`} /> @@ -23,6 +28,9 @@ export const AccountsTabs: React.FunctionComponent = ({ detai + ); diff --git a/console/src/app/AccountDetails/components/types.ts b/console/src/app/AccountDetails/components/types.ts index cfb1ca1..48e79f3 100644 --- a/console/src/app/AccountDetails/components/types.ts +++ b/console/src/app/AccountDetails/components/types.ts @@ -9,6 +9,7 @@ export interface AccountsHeaderProps { export interface AccountsTabsProps { detailsTabContent: React.ReactNode; clustersTabContent: React.ReactNode; + costsTabContent: React.ReactNode; } export interface AccountDetailsContentProps { From 7943017974cbf1b7e1b938d4cef02b72e06ed746 Mon Sep 17 00:00:00 2001 From: r2dedios Date: Thu, 25 Jun 2026 09:37:40 +0200 Subject: [PATCH 15/26] refactor(console): remove breadcrumbs from detail pages --- .../app/AccountDetails/components/AccountHeader.tsx | 12 +----------- .../components/ClusterDetailsOverview.tsx | 13 +------------ console/src/app/NodeDetails/NodeDetails.tsx | 11 ----------- 3 files changed, 2 insertions(+), 34 deletions(-) diff --git a/console/src/app/AccountDetails/components/AccountHeader.tsx b/console/src/app/AccountDetails/components/AccountHeader.tsx index a0f78ab..3a0a192 100644 --- a/console/src/app/AccountDetails/components/AccountHeader.tsx +++ b/console/src/app/AccountDetails/components/AccountHeader.tsx @@ -1,22 +1,12 @@ -import { Breadcrumb, BreadcrumbItem, Flex, FlexItem, PageSection, Title } from '@patternfly/react-core'; +import { Flex, FlexItem, PageSection, Title } from '@patternfly/react-core'; import { AccountsHeaderProps } from './types'; import { ResourceLabel } from '@app/utils/renderUtils'; import { AccountDetailsDropdown } from './AccountDetailsDropdown'; import React from 'react'; -import { Link } from 'react-router-dom'; export const AccountsHeader: React.FunctionComponent = ({ accountName, accountId }) => { return ( - - - Home - - - Accounts - - {accountName} - { return ( - - - Home - - - Clusters - - {cluster?.clusterName || clusterID} - { {/* Page header */} - - - Home - - - Nodes - - {instanceData?.instanceName || instanceID} - <ResourceLabel label="Node" color="#4cb140" /> {instanceData?.instanceName || instanceID} From cc0e0bf352c73dd472cdee5924dc6461f2e5de17 Mon Sep 17 00:00:00 2001 From: r2dedios Date: Thu, 25 Jun 2026 09:58:50 +0200 Subject: [PATCH 16/26] fix(console): prevent array mutation and null crash in useTableSort --- console/src/app/hooks/useTableSort.tsx | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/console/src/app/hooks/useTableSort.tsx b/console/src/app/hooks/useTableSort.tsx index 7cb1a6a..5a28de6 100644 --- a/console/src/app/hooks/useTableSort.tsx +++ b/console/src/app/hooks/useTableSort.tsx @@ -16,19 +16,16 @@ export function useTableSort( let sortedData = filteredData; if (typeof activeSortIndex === 'number' && activeSortIndex !== null) { - sortedData = filteredData.sort((a, b) => { - const aValue = getSortableRowValues(a)[activeSortIndex]; - const bValue = getSortableRowValues(b)[activeSortIndex]; + sortedData = [...filteredData].sort((a, b) => { + const aValue = getSortableRowValues(a)[activeSortIndex] ?? ''; + const bValue = getSortableRowValues(b)[activeSortIndex] ?? ''; - if (typeof aValue === 'number') { - return activeSortDirection === 'asc' - ? (aValue as number) - (bValue as number) - : (bValue as number) - (aValue as number); - } else { - return activeSortDirection === 'asc' - ? (aValue as string).localeCompare(bValue as string) - : (bValue as string).localeCompare(aValue as string); + if (typeof aValue === 'number' && typeof bValue === 'number') { + return activeSortDirection === 'asc' ? aValue - bValue : bValue - aValue; } + const aStr = String(aValue); + const bStr = String(bValue); + return activeSortDirection === 'asc' ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr); }); } From aa0b38bd21eddad65ef39d0e006c56c7df499eb6 Mon Sep 17 00:00:00 2001 From: r2dedios Date: Thu, 25 Jun 2026 09:58:54 +0200 Subject: [PATCH 17/26] fix(console): add useEffect cleanup to prevent unmounted state updates --- console/src/app/AccountDetails/AccountDetails.tsx | 9 +++++++-- .../app/AccountDetails/components/AccountClusters.tsx | 9 +++++++-- .../AccountDetails/components/AccountCostChart.tsx | 9 +++++++-- .../components/ClusterDetailsInstances.tsx | 9 +++++++-- .../components/ClusterDetailsOverview.tsx | 10 ++++++++-- console/src/app/NodeDetails/NodeDetails.tsx | 11 ++++++++--- 6 files changed, 44 insertions(+), 13 deletions(-) diff --git a/console/src/app/AccountDetails/AccountDetails.tsx b/console/src/app/AccountDetails/AccountDetails.tsx index 23951ef..51946df 100644 --- a/console/src/app/AccountDetails/AccountDetails.tsx +++ b/console/src/app/AccountDetails/AccountDetails.tsx @@ -15,20 +15,25 @@ const AccountDetails: React.FunctionComponent = () => { useDocumentTitle(`${accountData?.accountName || accountId} — ClusterIQ`); const [loading, setLoading] = useState(true); useEffect(() => { + let cancelled = false; const fetchData = async () => { try { debug('Fetching Account Clusters ', accountId); const { data: fetchedAccount } = await api.accounts.accountsDetail(accountId); + if (cancelled) return; setAccountData(fetchedAccount); debug('Fetched Account Clusters data:', fetchedAccount); } catch (error) { - console.error('Error fetching data:', error); + if (!cancelled) console.error('Error fetching data:', error); } finally { - setLoading(false); + if (!cancelled) setLoading(false); } }; fetchData(); + return () => { + cancelled = true; + }; }, [accountId]); return ( diff --git a/console/src/app/AccountDetails/components/AccountClusters.tsx b/console/src/app/AccountDetails/components/AccountClusters.tsx index 319eac0..9dccbf3 100644 --- a/console/src/app/AccountDetails/components/AccountClusters.tsx +++ b/console/src/app/AccountDetails/components/AccountClusters.tsx @@ -23,20 +23,25 @@ export const AccountClusters: React.FunctionComponent = () => { const { accountId } = useParams(); useEffect(() => { + let cancelled = false; const fetchData = async () => { try { debug('Fetching data...'); const { data } = await api.accounts.clustersList(accountId); + if (cancelled) return; debug('Fetched Account data:', data); setClusters(data.items || []); } catch (error) { - console.error('Error fetching data:', error); + if (!cancelled) console.error('Error fetching data:', error); } finally { - setLoading(false); + if (!cancelled) setLoading(false); } }; fetchData(); + return () => { + cancelled = true; + }; }, [accountId]); // Filter terminated clusters diff --git a/console/src/app/AccountDetails/components/AccountCostChart.tsx b/console/src/app/AccountDetails/components/AccountCostChart.tsx index 53bc6eb..62503f1 100644 --- a/console/src/app/AccountDetails/components/AccountCostChart.tsx +++ b/console/src/app/AccountDetails/components/AccountCostChart.tsx @@ -41,22 +41,27 @@ export const AccountCostChart: React.FC = ({ accountId }) const [loading, setLoading] = useState(true); useEffect(() => { + let cancelled = false; const fetchData = async () => { try { const [costsRes, clustersRes] = await Promise.all([ api.accounts.dailyCostsList(accountId), api.accounts.clustersList(accountId), ]); + if (cancelled) return; setDailyCosts(costsRes.data.items || []); setClusters(clustersRes.data.items || []); } catch (error) { - console.error('Error fetching cost evolution data:', error); + if (!cancelled) console.error('Error fetching cost evolution data:', error); } finally { - setLoading(false); + if (!cancelled) setLoading(false); } }; fetchData(); + return () => { + cancelled = true; + }; }, [accountId]); const dailyChartData = useMemo( diff --git a/console/src/app/ClusterDetails/components/ClusterDetailsInstances.tsx b/console/src/app/ClusterDetails/components/ClusterDetailsInstances.tsx index 3c6ef30..8685857 100644 --- a/console/src/app/ClusterDetails/components/ClusterDetailsInstances.tsx +++ b/console/src/app/ClusterDetails/components/ClusterDetailsInstances.tsx @@ -16,20 +16,25 @@ const ClusterDetailsInstances: React.FunctionComponent = () => { const [activeSortDirection, setActiveSortDirection] = useState<'asc' | 'desc'>('asc'); useEffect(() => { + let cancelled = false; const fetchData = async () => { try { console.log('Fetching data...'); const { data: fetchedInstancesPerCluster } = await api.clusters.instancesList(clusterID!); + if (cancelled) return; console.log('Fetched data:', fetchedInstancesPerCluster); setData(fetchedInstancesPerCluster); } catch (error) { - console.error('Error fetching data:', error); + if (!cancelled) console.error('Error fetching data:', error); } finally { - setLoading(false); + if (!cancelled) setLoading(false); } }; fetchData(); + return () => { + cancelled = true; + }; }, [clusterID]); if (!clusterID) { diff --git a/console/src/app/ClusterDetails/components/ClusterDetailsOverview.tsx b/console/src/app/ClusterDetails/components/ClusterDetailsOverview.tsx index cbe9717..5087c0a 100644 --- a/console/src/app/ClusterDetails/components/ClusterDetailsOverview.tsx +++ b/console/src/app/ClusterDetails/components/ClusterDetailsOverview.tsx @@ -37,21 +37,27 @@ const ClusterDetailsOverview: React.FunctionComponent = () => { useEffect(() => { if (!clusterID) return; + let cancelled = false; const fetchData = async () => { try { const { data: fetchedCluster } = await api.clusters.clustersDetail(clusterID!); + if (cancelled) return; setClusterData(fetchedCluster); const { data: fetchedTags } = await api.clusters.tagsList(clusterID!); + if (cancelled) return; setTagData(fetchedTags); } catch (error) { - console.error('Error fetching data:', error); + if (!cancelled) console.error('Error fetching data:', error); } finally { - setLoading(false); + if (!cancelled) setLoading(false); } }; fetchData(); + return () => { + cancelled = true; + }; }, [clusterID]); const filterTagsByKey = key => { diff --git a/console/src/app/NodeDetails/NodeDetails.tsx b/console/src/app/NodeDetails/NodeDetails.tsx index 8677dbf..6c3d5be 100644 --- a/console/src/app/NodeDetails/NodeDetails.tsx +++ b/console/src/app/NodeDetails/NodeDetails.tsx @@ -46,21 +46,26 @@ const NodeDetails: React.FunctionComponent = () => { const [loading, setLoading] = useState(true); useDocumentTitle(`${instanceData?.instanceName || instanceID || ''} — ClusterIQ`); useEffect(() => { + if (!instanceID) return; + let cancelled = false; const fetchData = async () => { try { console.log('Fetching Account Clusters ', instanceID); - if (!instanceID) return; const { data: fetchedInstance } = await api.instances.instancesDetail(instanceID); + if (cancelled) return; setInstanceData(fetchedInstance); console.log('Fetched Account Clusters data:', instanceID); } catch (error) { - console.error('Error fetching data:', error); + if (!cancelled) console.error('Error fetching data:', error); } finally { - setLoading(false); + if (!cancelled) setLoading(false); } }; fetchData(); + return () => { + cancelled = true; + }; }, [instanceID]); const handleTabClick = (_event, tabIndex) => { From f65143987349e9f1ab96221524b1a83013ba3ec4 Mon Sep 17 00:00:00 2001 From: r2dedios Date: Thu, 25 Jun 2026 09:59:02 +0200 Subject: [PATCH 18/26] feat(console): add 404 catch-all route with NotFound page --- console/src/app/NotFound/NotFound.tsx | 27 +++++++++++++++++++++++++++ console/src/app/index.tsx | 2 ++ 2 files changed, 29 insertions(+) create mode 100644 console/src/app/NotFound/NotFound.tsx diff --git a/console/src/app/NotFound/NotFound.tsx b/console/src/app/NotFound/NotFound.tsx new file mode 100644 index 0000000..938f83d --- /dev/null +++ b/console/src/app/NotFound/NotFound.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Button, EmptyState, EmptyStateBody, EmptyStateVariant, PageSection, Title } from '@patternfly/react-core'; +import { useNavigate } from 'react-router-dom'; + +const NotFound: React.FunctionComponent = () => { + const navigate = useNavigate(); + + return ( + + + 404: Page not found + + } + variant={EmptyStateVariant.full} + > + The page you are looking for does not exist. + + + + ); +}; + +export default NotFound; diff --git a/console/src/app/index.tsx b/console/src/app/index.tsx index bebbefc..f3c88cb 100644 --- a/console/src/app/index.tsx +++ b/console/src/app/index.tsx @@ -11,6 +11,7 @@ import AuditLogs from './Actions/AuditLogs/AuditLogs'; import Scheduler from './Actions/Scheduler/Schedule'; import Nodes from './Nodes/Nodes'; import Accounts from './Accounts/Accounts'; +import NotFound from './NotFound/NotFound'; import { NuqsAdapter } from 'nuqs/adapters/react'; import { UserProvider } from './Contexts/UserContext'; @@ -40,6 +41,7 @@ const AppRoutes = (): React.ReactElement => ( } /> } /> } /> + } /> ); From 4c661fc86bb610a3e130ebb563c01a34715fcd04 Mon Sep 17 00:00:00 2001 From: r2dedios Date: Thu, 25 Jun 2026 09:59:05 +0200 Subject: [PATCH 19/26] fix(console): add error handling to action creation modal --- .../Scheduler/components/ModalCreateAction.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/console/src/app/Actions/Scheduler/components/ModalCreateAction.tsx b/console/src/app/Actions/Scheduler/components/ModalCreateAction.tsx index f202050..d1f0365 100644 --- a/console/src/app/Actions/Scheduler/components/ModalCreateAction.tsx +++ b/console/src/app/Actions/Scheduler/components/ModalCreateAction.tsx @@ -1,4 +1,5 @@ import { + Alert, Button, Checkbox, FormHelperText, @@ -45,6 +46,7 @@ export const ModalCreateAction: React.FunctionComponent const [allAccounts, setAllAccounts] = React.useState([]); const [allClusters, setAllClusters] = React.useState([]); + const [submitError, setSubmitError] = React.useState(null); const isScan = actionOperation === ActionOperations.SCAN; @@ -138,6 +140,7 @@ export const ModalCreateAction: React.FunctionComponent setSelectedAccount(null); setSelectedCluster(null); setAllClusters([]); + setSubmitError(null); }, [isOpen]); const handlerConfirmActionCreation = async () => { @@ -165,9 +168,13 @@ export const ModalCreateAction: React.FunctionComponent } debug('Creating action', actionRequest); - await api.actions.actionsCreate([actionRequest]); - onCreated(); - onClose(); + try { + await api.actions.actionsCreate([actionRequest]); + onCreated(); + onClose(); + } catch (err) { + setSubmitError(err instanceof Error ? err.message : 'Failed to create action'); + } }; if (!isOpen) { @@ -190,6 +197,11 @@ export const ModalCreateAction: React.FunctionComponent ]} appendTo={document.body} > + {submitError && ( + + {submitError} + + )}
{/* Operation selection */} Date: Thu, 25 Jun 2026 10:03:02 +0200 Subject: [PATCH 20/26] refactor(console): replace console.log with debug utility --- .../ClusterDetails/components/ClusterDetailsEvents.tsx | 8 +++----- .../ClusterDetails/components/ClusterDetailsInstances.tsx | 7 ++++--- console/src/app/Contexts/UserContext.tsx | 3 ++- console/src/app/NodeDetails/NodeDetails.tsx | 5 +++-- console/src/app/index.tsx | 3 ++- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/console/src/app/ClusterDetails/components/ClusterDetailsEvents.tsx b/console/src/app/ClusterDetails/components/ClusterDetailsEvents.tsx index c023c77..866e13e 100644 --- a/console/src/app/ClusterDetails/components/ClusterDetailsEvents.tsx +++ b/console/src/app/ClusterDetails/components/ClusterDetailsEvents.tsx @@ -8,6 +8,7 @@ import { getResultIcon } from '@app/utils/renderUtils'; import { useTableSort } from '@app/hooks/useTableSort.tsx'; import { EmptyState } from '@patternfly/react-core'; import { SearchIcon } from '@patternfly/react-icons'; +import { debug } from '@app/utils/debugLogs'; interface TableEventsProps { data: SystemEventResponseApi[]; @@ -74,13 +75,11 @@ export const ClusterDetailsEvents: React.FunctionComponent = () => { try { const { data: clusterEvents } = await api.clusters.eventsList(clusterID); if (!cancelled) { - // TODO. Move to debug - console.log('Fetched events:', clusterEvents); + debug('Fetched events:', clusterEvents); setData(clusterEvents.items || []); } } catch (error) { if (!cancelled) { - // TODO. Move to debug console.error('Error fetching events:', error); } } finally { @@ -97,8 +96,7 @@ export const ClusterDetailsEvents: React.FunctionComponent = () => { }; }, [clusterID]); - // TODO. Move to debug - console.log('Rendered events data:', data); + debug('Rendered events data:', data); const getSortableRowValues = (event: SystemEventResponseApi): (string | number | null)[] => { const { action, result, severity, requester, description: description, timestamp } = event; diff --git a/console/src/app/ClusterDetails/components/ClusterDetailsInstances.tsx b/console/src/app/ClusterDetails/components/ClusterDetailsInstances.tsx index 8685857..84bdd92 100644 --- a/console/src/app/ClusterDetails/components/ClusterDetailsInstances.tsx +++ b/console/src/app/ClusterDetails/components/ClusterDetailsInstances.tsx @@ -5,6 +5,7 @@ import { api, InstanceResponseApi } from '@api'; import { ThProps, Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; import React, { useState, useEffect } from 'react'; import { useParams, Link } from 'react-router-dom'; +import { debug } from '@app/utils/debugLogs'; const ClusterDetailsInstances: React.FunctionComponent = () => { const { clusterID } = useParams(); @@ -19,10 +20,10 @@ const ClusterDetailsInstances: React.FunctionComponent = () => { let cancelled = false; const fetchData = async () => { try { - console.log('Fetching data...'); + debug('Fetching data...'); const { data: fetchedInstancesPerCluster } = await api.clusters.instancesList(clusterID!); if (cancelled) return; - console.log('Fetched data:', fetchedInstancesPerCluster); + debug('Fetched data:', fetchedInstancesPerCluster); setData(fetchedInstancesPerCluster); } catch (error) { if (!cancelled) console.error('Error fetching data:', error); @@ -41,7 +42,7 @@ const ClusterDetailsInstances: React.FunctionComponent = () => { return ; } - console.log('Rendered with data:', data); + debug('Rendered with data:', data); let sortedData = data; if (activeSortIndex !== undefined && activeSortDirection) { diff --git a/console/src/app/Contexts/UserContext.tsx b/console/src/app/Contexts/UserContext.tsx index f7d1118..0de5f52 100644 --- a/console/src/app/Contexts/UserContext.tsx +++ b/console/src/app/Contexts/UserContext.tsx @@ -1,5 +1,6 @@ /* eslint-disable react-refresh/only-export-components */ import * as React from 'react'; +import { debug } from '@app/utils/debugLogs'; interface UserContextType { userEmail: string | null; @@ -19,7 +20,7 @@ export const UserProvider: React.FC<{ children: React.ReactNode }> = ({ children .then(response => { const email = response.headers.get('gap-auth'); setUserEmail(email || 'clusteriq@dev'); - console.log('User email:', email || 'clusteriq@dev (fallback)'); + debug('User email:', email || 'clusteriq@dev (fallback)'); }) .catch(error => console.error('Error fetching headers:', error)); }, []); diff --git a/console/src/app/NodeDetails/NodeDetails.tsx b/console/src/app/NodeDetails/NodeDetails.tsx index 6c3d5be..ca01ab7 100644 --- a/console/src/app/NodeDetails/NodeDetails.tsx +++ b/console/src/app/NodeDetails/NodeDetails.tsx @@ -24,6 +24,7 @@ import { import { api, InstanceResponseApi, TagResponseApi } from '@api'; import { Link } from 'react-router-dom'; import { useDocumentTitle } from '@app/utils/useDocumentTitle'; +import { debug } from '@app/utils/debugLogs'; interface LabelGroupOverflowProps { labels: Array; @@ -50,11 +51,11 @@ const NodeDetails: React.FunctionComponent = () => { let cancelled = false; const fetchData = async () => { try { - console.log('Fetching Account Clusters ', instanceID); + debug('Fetching Account Clusters ', instanceID); const { data: fetchedInstance } = await api.instances.instancesDetail(instanceID); if (cancelled) return; setInstanceData(fetchedInstance); - console.log('Fetched Account Clusters data:', instanceID); + debug('Fetched Account Clusters data:', instanceID); } catch (error) { if (!cancelled) console.error('Error fetching data:', error); } finally { diff --git a/console/src/app/index.tsx b/console/src/app/index.tsx index f3c88cb..3435019 100644 --- a/console/src/app/index.tsx +++ b/console/src/app/index.tsx @@ -14,12 +14,13 @@ import Accounts from './Accounts/Accounts'; import NotFound from './NotFound/NotFound'; import { NuqsAdapter } from 'nuqs/adapters/react'; import { UserProvider } from './Contexts/UserContext'; +import { debug } from '@app/utils/debugLogs'; const RouteDebugWrapper = ({ children }: { children: React.ReactNode }) => { const location = useLocation(); React.useEffect(() => { - console.log('Route changed:', { + debug('Route changed:', { pathname: location.pathname, search: location.search, hash: location.hash, From 077ea15f6d3ab25c4ec0194d9d9ac6943b5b15ae Mon Sep 17 00:00:00 2001 From: r2dedios Date: Thu, 25 Jun 2026 10:03:18 +0200 Subject: [PATCH 21/26] refactor(console): remove dead commented code from SidebarNavigation --- console/src/app/AppLayout/SidebarNavigation.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/console/src/app/AppLayout/SidebarNavigation.tsx b/console/src/app/AppLayout/SidebarNavigation.tsx index 0aa0fc1..ba40af5 100644 --- a/console/src/app/AppLayout/SidebarNavigation.tsx +++ b/console/src/app/AppLayout/SidebarNavigation.tsx @@ -10,14 +10,6 @@ const SidebarNavigation: React.FunctionComponent = () => { location.pathname.startsWith('/clusters') || location.pathname.startsWith('/instances'); - //const isScanExpanded = location.pathname.startsWith('/scan'); - // - // - // - // Schedule - // - // - // const isActionsExpanded = location.pathname.startsWith('/actions'); return ( From 0160bd7648e3cb26734417b9a6d665278c71e58f Mon Sep 17 00:00:00 2001 From: r2dedios Date: Thu, 25 Jun 2026 10:03:26 +0200 Subject: [PATCH 22/26] refactor(console): replace any types with proper interfaces in CardRenderer --- console/src/app/Overview/Overview.tsx | 3 +-- .../app/Overview/components/CardRenderer.tsx | 22 +++++++++++-------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/console/src/app/Overview/Overview.tsx b/console/src/app/Overview/Overview.tsx index 925d8f7..116b755 100644 --- a/console/src/app/Overview/Overview.tsx +++ b/console/src/app/Overview/Overview.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import React from 'react'; import { Card, @@ -101,7 +100,7 @@ const AggregateStatusCards: React.FunctionComponent = () => { style={ { '--pf-v6-l-gallery--GridTemplateColumns--min': '22%', - } as any + } as React.CSSProperties } > {cardData.summaryCards.map((card, cardIndex) => ( diff --git a/console/src/app/Overview/components/CardRenderer.tsx b/console/src/app/Overview/components/CardRenderer.tsx index 94a12c5..7f0e32a 100644 --- a/console/src/app/Overview/components/CardRenderer.tsx +++ b/console/src/app/Overview/components/CardRenderer.tsx @@ -1,12 +1,11 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import React from 'react'; -import { CardTotalCount } from '@app/Overview/types.ts'; +import { CardContentItem, CardTotalCount } from '@app/Overview/types.ts'; import { Divider, Flex, FlexItem, Stack } from '@patternfly/react-core'; -// TODO Avoid any -export const RenderSingleIcon: React.FunctionComponent<{ content: any[] }> = ({ content }) => content[0]?.icon; -// TODO Avoid any -export const RenderMultiIcon: React.FunctionComponent<{ content: any[]; totalCount?: CardTotalCount }> = ({ +export const RenderSingleIcon: React.FunctionComponent<{ content: CardContentItem[] }> = ({ content }) => + content[0]?.icon; + +export const RenderMultiIcon: React.FunctionComponent<{ content: CardContentItem[]; totalCount?: CardTotalCount }> = ({ content, totalCount, }) => ( @@ -34,14 +33,19 @@ export const RenderMultiIcon: React.FunctionComponent<{ content: any[]; totalCou )} ); -// TODO Avoid any -export const RenderWithSubtitle: React.FC<{ content: any[] }> = ({ content }) => ( +interface SubtitleContentItem { + icon?: React.ReactNode; + status: string; + subtitle: string; +} + +export const RenderWithSubtitle: React.FC<{ content: SubtitleContentItem[] }> = ({ content }) => ( {content.map(({ icon, status, subtitle }, index) => ( {icon} - {status} + {status} {subtitle} From c02fa0598c8e4ad31b966eff83411794037d48e3 Mon Sep 17 00:00:00 2001 From: r2dedios Date: Thu, 25 Jun 2026 10:11:47 +0200 Subject: [PATCH 23/26] fix(console): stabilize debounce in toolbar search inputs --- .../AuditLogs/AuditLogsTableToolbar.tsx | 20 ++++++++++--------- .../components/ClustersTableToolbar.tsx | 18 ++++++++++------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/console/src/app/Actions/AuditLogs/AuditLogsTableToolbar.tsx b/console/src/app/Actions/AuditLogs/AuditLogsTableToolbar.tsx index e987bcd..bfbfdf3 100644 --- a/console/src/app/Actions/AuditLogs/AuditLogsTableToolbar.tsx +++ b/console/src/app/Actions/AuditLogs/AuditLogsTableToolbar.tsx @@ -36,17 +36,19 @@ export const AuditLogsTableToolbar: React.FunctionComponent { - const debouncedSearch = React.useMemo(() => debounce(setSearchValue, 300), [setSearchValue]); - const debouncedRequester = React.useMemo(() => debounce(setRequester, 300), [setRequester]); - const debouncedResult = React.useMemo(() => debounce(setResult, 300), [setResult]); + const debouncedSearchRef = React.useRef(debounce(setSearchValue, 300)); + React.useEffect(() => { + debouncedSearchRef.current = debounce(setSearchValue, 300); + return () => debouncedSearchRef.current.cancel(); + }, [setSearchValue]); + const debouncedSearch = React.useCallback((v: string) => debouncedSearchRef.current(v), []); + const debouncedRequesterRef = React.useRef(debounce(setRequester, 300)); React.useEffect(() => { - return () => { - debouncedSearch.cancel(); - debouncedRequester.cancel(); - debouncedResult.cancel(); - }; - }, [debouncedSearch, debouncedRequester, debouncedResult]); + debouncedRequesterRef.current = debounce(setRequester, 300); + return () => debouncedRequesterRef.current.cancel(); + }, [setRequester]); + const debouncedRequester = React.useCallback((v: string) => debouncedRequesterRef.current(v), []); // Set up name search input const searchInput = ( diff --git a/console/src/app/Clusters/components/ClustersTableToolbar.tsx b/console/src/app/Clusters/components/ClustersTableToolbar.tsx index bb6ee5b..cfb1a9f 100644 --- a/console/src/app/Clusters/components/ClustersTableToolbar.tsx +++ b/console/src/app/Clusters/components/ClustersTableToolbar.tsx @@ -34,15 +34,19 @@ export const ClustersTableToolbar: React.FunctionComponent { - const debouncedClusterSearch = React.useMemo(() => debounce(setClusterNameSearch, 300), [setClusterNameSearch]); - const debouncedAccountSearch = React.useMemo(() => debounce(setAccountNameSearch, 300), [setAccountNameSearch]); + const debouncedClusterSearchRef = React.useRef(debounce(setClusterNameSearch, 300)); + React.useEffect(() => { + debouncedClusterSearchRef.current = debounce(setClusterNameSearch, 300); + return () => debouncedClusterSearchRef.current.cancel(); + }, [setClusterNameSearch]); + const debouncedClusterSearch = React.useCallback((v: string) => debouncedClusterSearchRef.current(v), []); + const debouncedAccountSearchRef = React.useRef(debounce(setAccountNameSearch, 300)); React.useEffect(() => { - return () => { - debouncedClusterSearch.cancel(); - debouncedAccountSearch.cancel(); - }; - }, [debouncedClusterSearch, debouncedAccountSearch]); + debouncedAccountSearchRef.current = debounce(setAccountNameSearch, 300); + return () => debouncedAccountSearchRef.current.cancel(); + }, [setAccountNameSearch]); + const debouncedAccountSearch = React.useCallback((v: string) => debouncedAccountSearchRef.current(v), []); const [activeAttributeMenu, setActiveAttributeMenu] = React.useState< 'Cluster Name' | 'Account Name' | 'Status' | 'Provider' From 42147c5a36e2477a6cc528445efcffae0dff270c Mon Sep 17 00:00:00 2001 From: r2dedios Date: Thu, 25 Jun 2026 10:12:13 +0200 Subject: [PATCH 24/26] refactor(console): unify page_size limits and use stable list keys --- console/src/app/Overview/components/TopMetricCard.tsx | 2 +- console/src/app/hooks/useClusters.ts | 2 +- console/src/app/hooks/useInstances.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/console/src/app/Overview/components/TopMetricCard.tsx b/console/src/app/Overview/components/TopMetricCard.tsx index a9fc6c4..fa6c69b 100644 --- a/console/src/app/Overview/components/TopMetricCard.tsx +++ b/console/src/app/Overview/components/TopMetricCard.tsx @@ -34,7 +34,7 @@ export const TopMetricCard: React.FC = ({ title, items, form
{items.map((item, index) => (
({ queryKey: ['clusters'], queryFn: async ({ signal }) => { - const { data } = await api.clusters.clustersList({ page: 1, page_size: 100000 }, { signal }); + const { data } = await api.clusters.clustersList({ page: 1, page_size: 10000 }, { signal }); return data.items || []; }, refetchInterval: 10_000, diff --git a/console/src/app/hooks/useInstances.ts b/console/src/app/hooks/useInstances.ts index 010e814..5e8a705 100644 --- a/console/src/app/hooks/useInstances.ts +++ b/console/src/app/hooks/useInstances.ts @@ -5,7 +5,7 @@ export function useInstances() { return useQuery({ queryKey: ['instances'], queryFn: async ({ signal }) => { - const { data } = await api.instances.instancesList({ page: 1, page_size: 100000 }, { signal }); + const { data } = await api.instances.instancesList({ page: 1, page_size: 10000 }, { signal }); return data.items || []; }, refetchInterval: 10_000, From 21c431b7344bef4df00a2e3bae971e94a3efef7d Mon Sep 17 00:00:00 2001 From: r2dedios Date: Thu, 25 Jun 2026 11:26:15 +0200 Subject: [PATCH 25/26] docs(console): add comments to date formatters, pagination interceptor and theme logic --- console/src/api/index.ts | 3 +++ console/src/app/AccountDetails/components/AccountCostChart.tsx | 1 + console/src/app/AppLayout/AppLayout.tsx | 3 +-- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/console/src/api/index.ts b/console/src/api/index.ts index 227b183..7dbed81 100644 --- a/console/src/api/index.ts +++ b/console/src/api/index.ts @@ -18,6 +18,9 @@ const http = new HttpClient({ const PAGINATED_ENDPOINTS = ['/accounts', '/clusters', '/instances']; +// Auto-inject default pagination params on list endpoints. +// The regex excludes detail URLs with two trailing path segments (e.g. /clusters/abc-123 or /accounts/42/clusters) +// so pagination is only applied to collection endpoints like /clusters or /instances. http.instance.interceptors.request.use(config => { const isListEndpoint = PAGINATED_ENDPOINTS.some(endpoint => { const url = config.url || ''; diff --git a/console/src/app/AccountDetails/components/AccountCostChart.tsx b/console/src/app/AccountDetails/components/AccountCostChart.tsx index 62503f1..c796af4 100644 --- a/console/src/app/AccountDetails/components/AccountCostChart.tsx +++ b/console/src/app/AccountDetails/components/AccountCostChart.tsx @@ -16,6 +16,7 @@ interface AccountCostChartProps { const axisTextStyle = { fill: 'var(--pf-t--global--text--color--regular)' }; +// Date formatters: tickFormatDate for axis labels ("Jun 5"), formatFullDate for tooltips ("05/06/2026"), buildDateRange for card titles ("05/01/2026 — 05/06/2026") const tickFormatDate = (t: Date) => { const month = t.toLocaleString('default', { month: 'short' }); const day = t.getDate(); diff --git a/console/src/app/AppLayout/AppLayout.tsx b/console/src/app/AppLayout/AppLayout.tsx index 0d9bb9a..1dc916c 100644 --- a/console/src/app/AppLayout/AppLayout.tsx +++ b/console/src/app/AppLayout/AppLayout.tsx @@ -40,11 +40,10 @@ const AppLayout: React.FunctionComponent = ({ children }) => { const [isHelpMenuOpen, setIsHelpMenuOpen] = React.useState(false); const [isAboutModalOpen, setIsAboutModalOpen] = React.useState(false); const [isUserDropdownOpen, setIsUserDropdownOpen] = React.useState(false); + // Theme priority: localStorage ("theme" key) > OS preference (prefers-color-scheme) > light const [isDarkTheme, setIsDarkTheme] = React.useState(() => { const saved = localStorage.getItem('theme'); if (saved) return saved === 'dark'; - - // Fallback to browser preference return window.matchMedia('(prefers-color-scheme: dark)').matches; }); const isDesktop = () => window.innerWidth >= PF_BREAKPOINT_XL; From 44606d0af1daf19cc03d3fccef827c510a09567b Mon Sep 17 00:00:00 2001 From: r2dedios Date: Thu, 25 Jun 2026 11:56:47 +0200 Subject: [PATCH 26/26] feat(console): add ErrorBoundary and fix misleading debug messages --- .../src/app/AccountDetails/AccountDetails.tsx | 4 +- console/src/app/NodeDetails/NodeDetails.tsx | 4 +- .../app/components/common/ErrorBoundary.tsx | 45 +++++++++++++++++++ console/src/app/index.tsx | 5 ++- 4 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 console/src/app/components/common/ErrorBoundary.tsx diff --git a/console/src/app/AccountDetails/AccountDetails.tsx b/console/src/app/AccountDetails/AccountDetails.tsx index 51946df..fa0e4bd 100644 --- a/console/src/app/AccountDetails/AccountDetails.tsx +++ b/console/src/app/AccountDetails/AccountDetails.tsx @@ -18,11 +18,11 @@ const AccountDetails: React.FunctionComponent = () => { let cancelled = false; const fetchData = async () => { try { - debug('Fetching Account Clusters ', accountId); + debug('Fetching account detail:', accountId); const { data: fetchedAccount } = await api.accounts.accountsDetail(accountId); if (cancelled) return; setAccountData(fetchedAccount); - debug('Fetched Account Clusters data:', fetchedAccount); + debug('Fetched account detail:', fetchedAccount); } catch (error) { if (!cancelled) console.error('Error fetching data:', error); } finally { diff --git a/console/src/app/NodeDetails/NodeDetails.tsx b/console/src/app/NodeDetails/NodeDetails.tsx index ca01ab7..25dd425 100644 --- a/console/src/app/NodeDetails/NodeDetails.tsx +++ b/console/src/app/NodeDetails/NodeDetails.tsx @@ -51,11 +51,11 @@ const NodeDetails: React.FunctionComponent = () => { let cancelled = false; const fetchData = async () => { try { - debug('Fetching Account Clusters ', instanceID); + debug('Fetching instance detail:', instanceID); const { data: fetchedInstance } = await api.instances.instancesDetail(instanceID); if (cancelled) return; setInstanceData(fetchedInstance); - debug('Fetched Account Clusters data:', instanceID); + debug('Fetched instance detail:', instanceID); } catch (error) { if (!cancelled) console.error('Error fetching data:', error); } finally { diff --git a/console/src/app/components/common/ErrorBoundary.tsx b/console/src/app/components/common/ErrorBoundary.tsx new file mode 100644 index 0000000..d2cdac3 --- /dev/null +++ b/console/src/app/components/common/ErrorBoundary.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { + Button, + EmptyState, + EmptyStateBody, + EmptyStateFooter, + EmptyStateActions, + PageSection, +} from '@patternfly/react-core'; +import { ExclamationTriangleIcon } from '@patternfly/react-icons'; + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends React.Component<{ children: React.ReactNode }, ErrorBoundaryState> { + state: ErrorBoundaryState = { hasError: false, error: null }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + render() { + if (!this.state.hasError) return this.props.children; + + return ( + + + {this.state.error?.message || 'An unexpected error occurred.'} + + + + + + + + + ); + } +} diff --git a/console/src/app/index.tsx b/console/src/app/index.tsx index 3435019..e970d46 100644 --- a/console/src/app/index.tsx +++ b/console/src/app/index.tsx @@ -14,6 +14,7 @@ import Accounts from './Accounts/Accounts'; import NotFound from './NotFound/NotFound'; import { NuqsAdapter } from 'nuqs/adapters/react'; import { UserProvider } from './Contexts/UserContext'; +import { ErrorBoundary } from '@app/components/common/ErrorBoundary'; import { debug } from '@app/utils/debugLogs'; const RouteDebugWrapper = ({ children }: { children: React.ReactNode }) => { @@ -52,7 +53,9 @@ const App: React.FunctionComponent = () => ( - + + +