Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9261ac5
refactor(console): rename Instances to Nodes across the UI
r2dedios Jun 24, 2026
dc6b93b
feat(console): add icons to overview cards and fix icon sizing
r2dedios Jun 24, 2026
80bc627
feat(console): add breadcrumbs and document titles to detail pages
r2dedios Jun 24, 2026
6b126c8
feat(console): add badges, provider icons, and cost column to tables
r2dedios Jun 24, 2026
a383b76
feat(console): replace loading spinners with table skeleton loaders
r2dedios Jun 24, 2026
033dd17
feat(console): unify timestamps with relative time format
r2dedios Jun 24, 2026
8a3f346
feat(console): add dynamic browser tab titles to all pages
r2dedios Jun 24, 2026
3921521
fix(scanner): handle both date formats from AWS Cost Explorer response
r2dedios Jun 24, 2026
ea0fde2
fix(scanner): replace incorrect comments on expense date variables
r2dedios Jun 24, 2026
7e19ac2
fix(scanner): remove dead nil check on NewExpense return value
r2dedios Jun 24, 2026
a20b770
fix(scanner): use UTC for expense date boundaries in Cost Explorer qu…
r2dedios Jun 24, 2026
b83c426
fix(db): run partition creation daily instead of weekly
r2dedios Jun 24, 2026
7a609eb
feat(api): add daily-costs endpoint for account cost evolution
r2dedios Jun 25, 2026
84c1abd
feat(console): add cost evolution charts to account details page
r2dedios Jun 25, 2026
7943017
refactor(console): remove breadcrumbs from detail pages
r2dedios Jun 25, 2026
cc0e0bf
fix(console): prevent array mutation and null crash in useTableSort
r2dedios Jun 25, 2026
aa0b38b
fix(console): add useEffect cleanup to prevent unmounted state updates
r2dedios Jun 25, 2026
f651439
feat(console): add 404 catch-all route with NotFound page
r2dedios Jun 25, 2026
4c661fc
fix(console): add error handling to action creation modal
r2dedios Jun 25, 2026
7bf6170
refactor(console): replace console.log with debug utility
r2dedios Jun 25, 2026
077ea15
refactor(console): remove dead commented code from SidebarNavigation
r2dedios Jun 25, 2026
0160bd7
refactor(console): replace any types with proper interfaces in CardRe…
r2dedios Jun 25, 2026
c02fa05
fix(console): stabilize debounce in toolbar search inputs
r2dedios Jun 25, 2026
42147c5
refactor(console): unify page_size limits and use stable list keys
r2dedios Jun 25, 2026
21c431b
docs(console): add comments to date formatters, pagination intercepto…
r2dedios Jun 25, 2026
44606d0
feat(console): add ErrorBoundary and fix misleading debug messages
r2dedios Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
17 changes: 17 additions & 0 deletions console/src/api/Accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
AccountRequestApi,
AccountResponseApi,
ClusterListResponseApi,
DailyCostListResponseApi,
GenericErrorResponseApi,
InstanceListResponseApi,
PostResponseApi,
Expand Down Expand Up @@ -157,4 +158,20 @@ export class Accounts<SecurityDataType = unknown> {
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<DailyCostListResponseApi, GenericErrorResponseApi>({
path: `/accounts/${id}/daily-costs`,
method: 'GET',
type: ContentType.Json,
format: 'json',
...params,
});
}
10 changes: 10 additions & 0 deletions console/src/api/data-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
3 changes: 3 additions & 0 deletions console/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '';
Expand Down
17 changes: 13 additions & 4 deletions console/src/app/AccountDetails/AccountDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,36 @@ 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';

const AccountDetails: React.FunctionComponent = () => {
const { accountId } = useParams() as { accountId: string };
const [accountData, setAccountData] = useState<AccountResponseApi | null>(null);
useDocumentTitle(`${accountData?.accountName || accountId} — ClusterIQ`);
const [loading, setLoading] = useState(true);
useEffect(() => {
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) {
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 (
Expand All @@ -34,6 +42,7 @@ const AccountDetails: React.FunctionComponent = () => {
<AccountsTabs
detailsTabContent={<AccountDetailsContent loading={loading} accountData={accountData} />}
clustersTabContent={<AccountClusters />}
costsTabContent={<AccountCostChart accountId={accountId} />}
/>
</React.Fragment>
);
Expand Down
13 changes: 9 additions & 4 deletions console/src/app/AccountDetails/components/AccountClusters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand All @@ -47,7 +52,7 @@ export const AccountClusters: React.FunctionComponent = () => {
};

if (loading) {
return <LoadingSpinner />;
return <TableSkeleton columns={5} />;
}

return (
Expand Down
224 changes: 224 additions & 0 deletions console/src/app/AccountDetails/components/AccountCostChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
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)' };

// 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();
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<AccountCostChartProps> = ({ accountId }) => {
const [dailyCosts, setDailyCosts] = useState<DailyCostApi[]>([]);
const [clusters, setClusters] = useState<ClusterResponseApi[]>([]);
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) {
if (!cancelled) console.error('Error fetching cost evolution data:', error);
} finally {
if (!cancelled) setLoading(false);
}
};

fetchData();
return () => {
cancelled = true;
};
}, [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 (
<Grid hasGutter>
{[1, 2, 3].map(i => (
<GridItem key={i} span={4}>
<Card component="div" isFullHeight>
<CardTitle className="pf-v6-u-text-align-center">Loading...</CardTitle>
<CardBody>
<Skeleton height="300px" />
</CardBody>
</Card>
</GridItem>
))}
</Grid>
);
}

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 (
<Grid hasGutter>
<GridItem span={12} xl={4}>
<Card component="div" isFullHeight>
<CardTitle className="pf-v6-u-text-align-center">Daily Cost ({dateRange})</CardTitle>
<CardBody>
{dailyChartData.length === 0 ? (
<span className="pf-v6-u-color-200">No cost data available</span>
) : (
<div style={{ height: '300px', width: '100%' }}>
<Chart
height={300}
padding={{ bottom: 80, left: 60, right: 20, top: 20 }}
themeColor={ChartThemeColor.blue}
domain={{ y: [0, maxDaily * 1.1] }}
scale={{ x: 'time' }}
containerComponent={
<ChartVoronoiContainer
labels={({ datum }: { datum: { x: Date; y: number } }) =>
`${formatFullDate(datum.x)}: $${datum.y.toFixed(2)}`
}
constrainToVisibleArea
/>
}
>
<ChartAxis fixLabelOverlap tickFormat={tickFormatDate} style={xAxisStyle} />
<ChartAxis dependentAxis showGrid tickFormat={(t: number) => `$${t.toFixed(2)}`} style={yAxisStyle} />
<ChartArea data={dailyChartData} interpolation="monotoneX" />
</Chart>
</div>
)}
</CardBody>
</Card>
</GridItem>

<GridItem span={12} xl={4}>
<Card component="div" isFullHeight>
<CardTitle className="pf-v6-u-text-align-center">Cumulative Cost ({dateRange})</CardTitle>
<CardBody>
{cumulativeChartData.length === 0 ? (
<span className="pf-v6-u-color-200">No cost data available</span>
) : (
<div style={{ height: '300px', width: '100%' }}>
<Chart
height={300}
padding={{ bottom: 80, left: 60, right: 20, top: 20 }}
themeColor={ChartThemeColor.blue}
domain={{ y: [0, maxCumulative * 1.1] }}
scale={{ x: 'time' }}
containerComponent={
<ChartVoronoiContainer
labels={({ datum }: { datum: { x: Date; y: number } }) =>
`${formatFullDate(datum.x)}: $${datum.y.toFixed(2)}`
}
constrainToVisibleArea
/>
}
>
<ChartAxis fixLabelOverlap tickFormat={tickFormatDate} style={xAxisStyle} />
<ChartAxis dependentAxis showGrid tickFormat={(t: number) => `$${t.toFixed(0)}`} style={yAxisStyle} />
<ChartArea data={cumulativeChartData} interpolation="monotoneX" />
</Chart>
</div>
)}
</CardBody>
</Card>
</GridItem>

<GridItem span={12} xl={4}>
<Card component="div" isFullHeight>
<CardTitle className="pf-v6-u-text-align-center">Cluster Count ({dateRange})</CardTitle>
<CardBody>
{clusterCountData.length === 0 ? (
<span className="pf-v6-u-color-200">No cluster data available</span>
) : (
<div style={{ height: '300px', width: '100%' }}>
<Chart
height={300}
padding={{ bottom: 80, left: 60, right: 20, top: 20 }}
themeColor={ChartThemeColor.green}
domain={{ y: [0, maxClusters + 1] }}
scale={{ x: 'time' }}
containerComponent={
<ChartVoronoiContainer
labels={({ datum }: { datum: { x: Date; y: number } }) =>
`${formatFullDate(datum.x)}: ${datum.y} clusters`
}
constrainToVisibleArea
/>
}
>
<ChartAxis fixLabelOverlap tickFormat={tickFormatDate} style={xAxisStyle} />
<ChartAxis dependentAxis showGrid tickFormat={(t: number) => `${Math.round(t)}`} style={yAxisStyle} />
<ChartLine data={clusterCountData} interpolation="stepAfter" />
</Chart>
</div>
)}
</CardBody>
</Card>
</GridItem>
</Grid>
);
};
Loading
Loading