Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
735 changes: 728 additions & 7 deletions console/package-lock.json

Large diffs are not rendered by default.

20 changes: 19 additions & 1 deletion console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"clean": "rm -Rf dist"
},
"dependencies": {
"@patternfly/react-charts": "^8.5.1",
"@patternfly/react-core": "^6.4.0",
"@patternfly/react-icons": "^6.4.0",
"@patternfly/react-styles": "^6.4.0",
Expand All @@ -25,7 +26,24 @@
"nuqs": "^2.2.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
"react-router-dom": "^6.28.0",
"victory-area": "^37.3.6",
"victory-axis": "^37.3.6",
"victory-bar": "^37.3.6",
"victory-box-plot": "^37.3.6",
"victory-chart": "^37.3.6",
"victory-core": "^37.3.6",
"victory-create-container": "^37.3.6",
"victory-cursor-container": "^37.3.6",
"victory-group": "^37.3.6",
"victory-legend": "^37.3.6",
"victory-line": "^37.3.6",
"victory-pie": "^37.3.6",
"victory-scatter": "^37.3.6",
"victory-stack": "^37.3.6",
"victory-tooltip": "^37.3.6",
"victory-voronoi-container": "^37.3.6",
"victory-zoom-container": "^37.3.6"
},
"devDependencies": {
"@eslint/compat": "^1.2.4",
Expand Down
14 changes: 14 additions & 0 deletions console/src/api/data-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,11 +237,25 @@ export interface InstancesSummaryApi {
stopped?: number;
}

export interface TopItemApi {
name?: string;
clusterCount?: number;
}

export interface AccountCostApi {
accountName?: string;
currentMonthCost?: number;
}

export interface OverviewSummaryApi {
clusters?: ClusterSummaryApi;
instances?: InstancesSummaryApi;
providers?: ProvidersSummaryApi;
scanner?: ScannerApi;
topRegions?: TopItemApi[];
topOwners?: TopItemApi[];
clustersByPartner?: TopItemApi[];
costPerAccount?: AccountCostApi[];
}

export interface PostResponseApi {
Expand Down
4 changes: 4 additions & 0 deletions console/src/app/Overview/Overview.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.overview-card {
border: 1px solid var(--pf-t--global--border--color--default);
border-radius: var(--pf-t--global--border--radius--medium);
}
124 changes: 69 additions & 55 deletions console/src/app/Overview/Overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import {
CardBody,
CardTitle,
Gallery,
Grid,
GridItem,
PageSection,
Content,
Alert,
Expand All @@ -19,11 +17,14 @@ import {
import { CubesIcon } from '@patternfly/react-icons';
import { LoadingSpinner } from '@app/components/common/LoadingSpinner';
import { generateCards } from './components/CardData';
import { ProviderApi } from '@api';
import { PartnerDonutChart } from './components/PartnerDonutChart';
import { TopMetricCard } from './components/TopMetricCard';
import { ProviderApi, TopItemApi } from '@api';
import { renderContent } from './utils/cardRendererUtils.tsx';
import { useDashboardData } from './hooks/useDashboardData';
import { useEventsData } from './hooks/useEventsData';
import { DashboardState } from './types';
import './Overview.css';

const AggregateStatusCards: React.FunctionComponent = () => {
const { inventoryData, loading, error } = useDashboardData();
Expand Down Expand Up @@ -56,11 +57,6 @@ const AggregateStatusCards: React.FunctionComponent = () => {
stopped: inventoryData?.clusters?.stopped || 0,
terminated: inventoryData?.clusters?.archived || 0,
},
instancesByStatus: {
running: inventoryData?.instances?.running || 0,
stopped: inventoryData?.instances?.stopped || 0,
terminated: inventoryData?.instances?.archived || 0,
},
clustersByProvider: {
[ProviderApi.AWSProvider]: inventoryData.providers?.aws?.clusterCount || 0,
[ProviderApi.GCPProvider]: inventoryData.providers?.gcp?.clusterCount || 0,
Expand All @@ -73,10 +69,19 @@ const AggregateStatusCards: React.FunctionComponent = () => {
[ProviderApi.AzureProvider]: inventoryData.providers?.azure?.accountCount || 0,
[ProviderApi.UnknownProvider]: 0,
},
instances: (inventoryData?.instances?.running || 0) + (inventoryData?.instances?.stopped || 0),
lastScanTimestamp: inventoryData?.scanner?.lastScanTimestamp,
topRegions: inventoryData?.topRegions || [],
topOwners: inventoryData?.topOwners || [],
clustersByPartner: inventoryData?.clustersByPartner || [],
costPerAccount: inventoryData?.costPerAccount || [],
};

const costAsTopItems: TopItemApi[] = (dashboardState.costPerAccount || []).map(a => ({
name: a.accountName,
clusterCount: a.currentMonthCost,
}));
const formatCost = (v: number) => `$${v.toFixed(2)}`;

const cardData = generateCards(dashboardState, events);

return (
Expand All @@ -87,54 +92,63 @@ const AggregateStatusCards: React.FunctionComponent = () => {
</Content>
</PageSection>
<PageSection hasBodyWrapper={false}>
<Grid hasGutter>
{Object.entries(cardData).map(([groupName, cards], groupIndex) => (
<GridItem key={groupIndex} span={groupName === 'activityCards' ? 12 : undefined}>
{groupName === 'activityCards' ? (
// Full width Activity card with double height
<Card className="pf-v6-u-min-height" component="div">
<CardTitle className="pf-v6-u-text-align-center">{cards[0].title}</CardTitle>
<CardBody className="pf-v6-u-p-md">
{eventsLoading ? (
<LoadingSpinner />
) : eventsError ? (
<Alert variant="danger" title="Unable to load events" isInline>
<p>{eventsError}</p>
<p>Check the console for more details or try refreshing the page.</p>
</Alert>
) : cards[0].customComponent ? (
cards[0].customComponent
) : (
renderContent(cards[0].content, cards[0].layout, cards[0].totalCount)
)}
</CardBody>
</Card>
) : (
// Regular cards in Gallery
<Gallery
hasGutter
style={
{
'--pf-v6-l-gallery--GridTemplateColumns--min': '30%',
} as any
}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{/* Row 1: Summary cards */}
<Gallery
hasGutter
style={
{
'--pf-v6-l-gallery--GridTemplateColumns--min': '22%',
} as any
}
>
{cardData.summaryCards.map((card, cardIndex) => (
<Card key={cardIndex} component="div" className="pf-v6-u-min-height overview-card">
<CardTitle
className="pf-v6-u-text-align-center"
style={{ textAlign: 'center', justifyContent: 'center' }}
>
{cards.map((card, cardIndex) => (
<Card key={`${groupIndex}${cardIndex}`} component="div" className="pf-v6-u-min-height">
<CardTitle
className="pf-v6-u-text-align-center"
style={{ textAlign: 'center', justifyContent: 'center' }}
>
{card.title}
</CardTitle>
<CardBody>{renderContent(card.content, card.layout, card.totalCount)}</CardBody>
</Card>
))}
</Gallery>
{card.title}
</CardTitle>
<CardBody>{renderContent(card.content, card.layout, card.totalCount)}</CardBody>
</Card>
))}
</Gallery>

{/* Row 2: Partner chart + ranked lists */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
<PartnerDonutChart data={dashboardState.clustersByPartner} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
<TopMetricCard title="Cost per Account" items={costAsTopItems} formatValue={formatCost} />
<TopMetricCard title="Top Regions" items={dashboardState.topRegions} />
<TopMetricCard title="Top Owners" items={dashboardState.topOwners} />
<TopMetricCard title="Top Partners" items={dashboardState.clustersByPartner.slice(0, 5)} />
</div>
</div>

{/* Row 4: Recent Events */}
<Card className="pf-v6-u-min-height overview-card" component="div">
<CardTitle className="pf-v6-u-text-align-center">{cardData.activityCards[0].title}</CardTitle>
<CardBody className="pf-v6-u-p-md">
{eventsLoading ? (
<LoadingSpinner />
) : eventsError ? (
<Alert variant="danger" title="Unable to load events" isInline>
<p>{eventsError}</p>
<p>Check the console for more details or try refreshing the page.</p>
</Alert>
) : cardData.activityCards[0].customComponent ? (
cardData.activityCards[0].customComponent
) : (
renderContent(
cardData.activityCards[0].content,
cardData.activityCards[0].layout,
cardData.activityCards[0].totalCount
)
)}
</GridItem>
))}
</Grid>
</CardBody>
</Card>
</div>
</PageSection>
</React.Fragment>
);
Expand Down
68 changes: 34 additions & 34 deletions console/src/app/Overview/components/CardData.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,35 +12,53 @@ export const generateCards = (
const scannerContent = isValidTimestamp
? `${new Date(state.lastScanTimestamp!).toLocaleString()}`
: 'No scan data available';
const totalClusters = (state.clustersByStatus.running || 0) + (state.clustersByStatus.stopped || 0);
const totalInstances = state.instances || 0;

const statusCards = [
const totalAccounts = Object.values(state.accountsByProvider).reduce((sum, count) => sum + count, 0);
const totalClustersByProvider =
Object.values(state.clustersByProvider).reduce((sum, count) => sum + count, 0) -
(state.clustersByStatus.terminated || 0);
const totalClustersByStatus = (state.clustersByStatus.running || 0) + (state.clustersByStatus.stopped || 0);

const summaryCards: CardDefinition[] = [
{
title: 'Clusters',
content: Object.entries(STATUSES).map(([key, status]) => ({
icon: status.icon,
value: state.clustersByStatus[key] || 0,
ref: status.route,
title: 'Accounts',
content: Object.values(CLOUD_PROVIDERS).map(provider => ({
icon: provider.providerIcon,
value: state.accountsByProvider[provider.key] ?? 0,
ref: `/accounts?provider=${provider.key}`,
})),
layout: CardLayout.MULTI_ICON,
totalCount: {
icon: TOTAL_COUNT_ICONS.clusters,
value: totalAccounts,
label: 'Total',
},
},
{
title: 'Clusters by Provider',
content: Object.values(CLOUD_PROVIDERS).map(provider => ({
icon: provider.icon,
value: state.clustersByProvider[provider.key] ?? 0,
ref: `/clusters?provider=${provider.key}`,
})),
layout: CardLayout.MULTI_ICON,
totalCount: {
icon: TOTAL_COUNT_ICONS.clusters,
value: totalClusters,
value: totalClustersByProvider,
label: 'Total',
},
},
{
title: 'Instances',
title: 'Clusters by Status',
content: Object.entries(STATUSES).map(([key, status]) => ({
icon: status.icon,
value: state.instancesByStatus[key] || 0,
value: state.clustersByStatus[key] || 0,
ref: status.route,
})),
layout: CardLayout.MULTI_ICON,
totalCount: {
icon: TOTAL_COUNT_ICONS.instances,
value: totalInstances,
icon: TOTAL_COUNT_ICONS.clusters,
value: totalClustersByStatus,
label: 'Total',
},
},
Expand All @@ -51,35 +69,17 @@ export const generateCards = (
},
];

const providerCards = Object.values(CLOUD_PROVIDERS).map(provider => ({
title: provider.title,
content: [
{
value: `${state.clustersByProvider[provider.key] ?? 0} Cluster(s)`,
icon: provider.icon,
ref: `/clusters?provider=${provider.key}`,
},
{
value: `${state.accountsByProvider[provider.key] ?? 0} Account(s)`,
icon: provider.providerIcon,
ref: `/accounts?provider=${provider.key}`,
},
],
layout: CardLayout.MULTI_ICON,
}));

const activityCards = [
const activityCards: CardDefinition[] = [
{
title: 'Recent events',
content: [], // Empty content since we're using customComponent
content: [],
layout: CardLayout.MULTI_ICON,
customComponent: <ActivityTable events={events} />,
},
];

return {
statusCards,
providerCards,
summaryCards,
activityCards,
};
};
54 changes: 54 additions & 0 deletions console/src/app/Overview/components/CostBarChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React from 'react';
import { Card, CardBody, CardTitle } from '@patternfly/react-core';
import { Chart, ChartBar, ChartAxis, ChartGroup, ChartThemeColor } from '@patternfly/react-charts/victory';
import { AccountCostApi } from '@api';

interface CostBarChartProps {
data: AccountCostApi[];
}

const axisTextStyle = { fill: 'var(--pf-t--global--text--color--regular)' };

export const CostBarChart: React.FC<CostBarChartProps> = ({ data }) => {
const chartData = (data || []).map(item => ({
x: item.accountName || 'Unknown',
y: item.currentMonthCost ?? 0,
}));

const maxCost = Math.max(...chartData.map(d => d.y), 1);

return (
<Card component="div" isFullHeight className="overview-card">
<CardTitle className="pf-v6-u-text-align-center">Cost per Account (Current Month)</CardTitle>
<CardBody>
{chartData.length === 0 ? (
<span className="pf-v6-u-color-200">No cost data available</span>
) : (
<div style={{ height: '280px', width: '100%' }}>
<Chart
domainPadding={{ x: [30, 30] }}
height={280}
padding={{ bottom: 80, left: 80, right: 30, top: 20 }}
themeColor={ChartThemeColor.blue}
domain={{ y: [0, maxCost * 1.1] }}
>
<ChartAxis
fixLabelOverlap
style={{ tickLabels: { ...axisTextStyle, angle: -35, textAnchor: 'end', fontSize: 12 } }}
/>
<ChartAxis
dependentAxis
showGrid
tickFormat={(t: number) => `$${t.toFixed(0)}`}
style={{ tickLabels: { ...axisTextStyle, fontSize: 12 } }}
/>
<ChartGroup>
<ChartBar data={chartData} />
</ChartGroup>
</Chart>
</div>
)}
</CardBody>
</Card>
);
};
Loading
Loading