Skip to content

Commit 4801590

Browse files
authored
Merge pull request #15 from youenchene/compared-graph
feat: Add support for comparing service groups in cloud spending
2 parents 61bc96d + b9c179e commit 4801590

6 files changed

Lines changed: 226 additions & 3 deletions

File tree

README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The generated KPI are :
88
- **Lead time** and **cycle time** trend
99
- **Stocks** per week : **Red Bin** for bug and **stocks** per **development process steps**.
1010
- **Statistics controlled** weekly **throughput** (issues closed per week)
11-
- **Cloud spending follow-up**: monthly Azure & GCP costs overall and per service/group
11+
- **Cloud spending follow-up**: monthly Azure & GCP costs overall, per service/group and **compared groups**.
1212

1313
Current sources are :
1414
- github issues of a github organization.
@@ -73,6 +73,7 @@ Basic indicator to identify Change request event per week on pull requests.
7373
Tracks cloud infrastructure spending over time from Azure and GCP. Two visualizations are provided:
7474
- **Overall costs per month**: Shows total spending per cloud provider (Azure & GCP) aggregated monthly.
7575
- **Per service/group costs per month**: Shows spending breakdown by logical groups (preferred) or by individual services, as configured in `config.yml`.
76+
- **Compared service groups per month**: Shows a side-by-side comparison of two or more service groups (e.g., Old Platform vs New Platform), as configured in `config.yml`.
7677

7778
This helps identify cost trends, compare spending across providers, and track specific services that contribute most to cloud expenses.
7879

@@ -227,6 +228,7 @@ Available API endpoints :
227228
- GET /api/pr/change_requests/repo_dist → data/pr_change_requests_repo_dist.csv
228229
- GET /api/cloud_spending/monthly → data/cloud_spending_monthly.csv
229230
- GET /api/cloud_spending/services → data/cloud_spending_services.csv
231+
- GET /api/cloud_spending/compared → data/cloud_spending_compared.csv
230232

231233
Cloud Spending CSV formats:
232234

@@ -241,6 +243,11 @@ Cloud Spending CSV formats:
241243
- Rows are aggregated by month, provider, and group/service, and currency (no cross-currency mixing).
242244
- If you configure `cloudspending.detailed_service` with groups, only services belonging to the defined groups are included (and exposed under the `group` column). If you configure a flat list (legacy), only those services are included (under the `service` column).
243245

246+
- data/cloud_spending_compared.csv
247+
- Headers: `comparison,month,group,cost,currency`
248+
- Rows are aggregated by comparison name, month, group name and currency.
249+
- Used for side-by-side comparison charts.
250+
244251
### Configuration (config.yml)
245252

246253
The `config.yml` file allows customization of GitHub project mappings and cloud spending service filters.
@@ -260,6 +267,16 @@ cloudspending:
260267
services:
261268
- "Cloud SQL"
262269
- "BigQuery"
270+
compared_service:
271+
- name: "Old Platform versus New Platform on GCP"
272+
groups:
273+
- name: "GCP Legacy"
274+
services:
275+
- "Compute Engine"
276+
- name: "GCP New Platform"
277+
services:
278+
- "Cloud Run"
279+
- "BigQuery"
263280
```
264281
265282
Legacy/alternate shapes also supported (backward compatible):

command/calculate/calculate.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1583,13 +1583,17 @@ func runCloudSpendingCalculate() error {
15831583

15841584
var serviceFilter []string
15851585
var groups []config.DetailedServiceGroup
1586+
var compared []config.ComparedService
15861587
if _, err := os.Stat(cfgPath); err == nil {
15871588
cfg, err := config.Load(cfgPath)
15881589
if err == nil {
15891590
serviceFilter = cfg.CloudSpending.Services
15901591
if len(cfg.CloudSpending.DetailedService) > 0 {
15911592
groups = cfg.CloudSpending.DetailedService
15921593
}
1594+
if len(cfg.CloudSpending.ComparedService) > 0 {
1595+
compared = cfg.CloudSpending.ComparedService
1596+
}
15931597
}
15941598
}
15951599

@@ -1619,6 +1623,15 @@ func runCloudSpendingCalculate() error {
16191623
}
16201624
slog.Info("cloudspending.calculate.services.done", "output", servicesPath)
16211625

1626+
// Aggregate compared services
1627+
if len(compared) > 0 {
1628+
comparedPath := filepath.Join("data", "cloud_spending_compared.csv")
1629+
if err := writeCloudSpendingCompared(comparedPath, records, compared); err != nil {
1630+
return fmt.Errorf("failed to write compared aggregation: %w", err)
1631+
}
1632+
slog.Info("cloudspending.calculate.compared.done", "output", comparedPath)
1633+
}
1634+
16221635
slog.Info("cloudspending.calculate.done")
16231636
return nil
16241637
}
@@ -1878,3 +1891,99 @@ func writeCloudSpendingServices(path string, records []cloudCostRecord, groups [
18781891

18791892
return w.Error()
18801893
}
1894+
1895+
func writeCloudSpendingCompared(path string, records []cloudCostRecord, comparisons []config.ComparedService) error {
1896+
// Build map: comparison_name -> service -> group_name
1897+
compToServiceToGroup := make(map[string]map[string]string)
1898+
for _, comp := range comparisons {
1899+
serviceToGroup := make(map[string]string)
1900+
for _, g := range comp.Groups {
1901+
gname := strings.TrimSpace(g.Name)
1902+
for _, s := range g.Services {
1903+
serviceToGroup[strings.TrimSpace(s)] = gname
1904+
}
1905+
}
1906+
compToServiceToGroup[comp.Name] = serviceToGroup
1907+
}
1908+
1909+
type key struct {
1910+
Comparison string
1911+
Group string
1912+
Month string
1913+
Currency string
1914+
}
1915+
agg := make(map[key]float64)
1916+
1917+
for _, r := range records {
1918+
month := r.Month.Format("2006-01")
1919+
currency := strings.TrimSpace(r.Currency)
1920+
1921+
for _, comp := range comparisons {
1922+
serviceToGroup := compToServiceToGroup[comp.Name]
1923+
gname, ok := serviceToGroup[r.Service]
1924+
if !ok || gname == "" {
1925+
continue
1926+
}
1927+
1928+
k := key{
1929+
Comparison: comp.Name,
1930+
Group: gname,
1931+
Month: month,
1932+
Currency: currency,
1933+
}
1934+
agg[k] += r.Cost
1935+
}
1936+
}
1937+
1938+
type row struct {
1939+
Comparison string
1940+
Month string
1941+
Group string
1942+
Cost float64
1943+
Currency string
1944+
}
1945+
var rows []row
1946+
for k, cost := range agg {
1947+
rows = append(rows, row{
1948+
Comparison: k.Comparison,
1949+
Month: k.Month,
1950+
Group: k.Group,
1951+
Cost: cost,
1952+
Currency: k.Currency,
1953+
})
1954+
}
1955+
sort.Slice(rows, func(i, j int) bool {
1956+
if rows[i].Comparison != rows[j].Comparison {
1957+
return rows[i].Comparison < rows[j].Comparison
1958+
}
1959+
if rows[i].Month != rows[j].Month {
1960+
return rows[i].Month < rows[j].Month
1961+
}
1962+
if rows[i].Group != rows[j].Group {
1963+
return rows[i].Group < rows[j].Group
1964+
}
1965+
return rows[i].Currency < rows[j].Currency
1966+
})
1967+
1968+
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
1969+
return err
1970+
}
1971+
f, err := os.Create(path)
1972+
if err != nil {
1973+
return err
1974+
}
1975+
defer f.Close()
1976+
1977+
w := csv.NewWriter(f)
1978+
defer w.Flush()
1979+
1980+
if err := w.Write([]string{"comparison", "month", "group", "cost", "currency"}); err != nil {
1981+
return err
1982+
}
1983+
for _, r := range rows {
1984+
if err := w.Write([]string{r.Comparison, r.Month, r.Group, fmt.Sprintf("%.2f", r.Cost), r.Currency}); err != nil {
1985+
return err
1986+
}
1987+
}
1988+
return w.Error()
1989+
}

command/web/web.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ func Run(args []string) error {
7171
serveCSV("/api/pr/change_requests/repo_dist", "pr_change_requests_repo_dist.csv")
7272
serveCSV("/api/cloud_spending/monthly", "cloud_spending_monthly.csv")
7373
serveCSV("/api/cloud_spending/services", "cloud_spending_services.csv")
74+
serveCSV("/api/cloud_spending/compared", "cloud_spending_compared.csv")
7475

7576
// Static UI (optional)
7677
indexPath := filepath.Join(*uiDir, "index.html")

connectors/config/config.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,22 @@ type Config struct {
2222
Services []string `yaml:"services"`
2323
// Grouped services: each logical name aggregates several concrete services
2424
DetailedService []DetailedServiceGroup `yaml:"detailed_service"`
25+
// Compared services: list of comparisons between two groups of services
26+
ComparedService []ComparedService `yaml:"compared_service"`
2527
} `yaml:"cloud_spending"`
2628
// Backward/forward compatibility alias to support alternate YAML shape:
2729
// cloudspending:
2830
// detailed_service:
2931
// - name: "AI"
3032
// services: ["Vertex AI", "Claude Sonnet 4.5"]
33+
// compared_service:
34+
// - name: "Old vs New"
35+
// groups: [...]
3136
// or (legacy flat list): ["Vertex AI", "Compute Engine", ...]
3237
// If provided, we map it to CloudSpending.DetailedService or Services so downstream code keeps working.
3338
CloudSpendingAlt struct {
34-
DetailedService any `yaml:"detailed_service"`
39+
DetailedService any `yaml:"detailed_service"`
40+
ComparedService []ComparedService `yaml:"compared_service"`
3541
} `yaml:"cloudspending"`
3642
}
3743

@@ -41,6 +47,12 @@ type DetailedServiceGroup struct {
4147
Services []string `yaml:"services"`
4248
}
4349

50+
// ComparedService defines a comparison between several groups of services.
51+
type ComparedService struct {
52+
Name string `yaml:"name"`
53+
Groups []DetailedServiceGroup `yaml:"groups"`
54+
}
55+
4456
type Project struct {
4557
ID string `yaml:"id"`
4658
Name string `yaml:"name"`
@@ -114,6 +126,9 @@ func Load(path string) (*Config, error) {
114126
}
115127
}
116128
// If only the new canonical grouped field is provided under cloud_spending, keep as is.
129+
if len(c.CloudSpendingAlt.ComparedService) > 0 {
130+
c.CloudSpending.ComparedService = c.CloudSpendingAlt.ComparedService
131+
}
117132
slog.Info(fmt.Sprintf("Loaded config: %s", path))
118133
return &c, nil
119134
}

ui/src/App.tsx

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useEffect, useMemo, useRef, useState } from 'react'
2-
import { useCycleTimes, useStocks, useStocksWeek, useThroughputWeek, usePRChangeRequestsWeek, useCloudSpendingMonthly, useCloudSpendingServices } from './api'
2+
import { useCycleTimes, useStocks, useStocksWeek, useThroughputWeek, usePRChangeRequestsWeek, useCloudSpendingMonthly, useCloudSpendingServices, useCloudSpendingCompared } from './api'
33
import { Card, CardContent, CardHeader, CardTitle } from './components/ui/card'
44
import { Sparkline } from './components/Sparkline'
55
import { LineChart, Point } from './components/LineChart'
@@ -405,9 +405,11 @@ function CloudSpendingBlock() {
405405
const { t } = useTranslation()
406406
const monthlyQuery = useCloudSpendingMonthly()
407407
const servicesQuery = useCloudSpendingServices()
408+
const comparedQuery = useCloudSpendingCompared()
408409

409410
const monthlyData = monthlyQuery.data ?? []
410411
const servicesData = servicesQuery.data ?? []
412+
const comparedData = comparedQuery.data ?? []
411413

412414
// Process overall monthly data (Azure & GCP per month)
413415
const monthlyLabels = useMemo(() => {
@@ -506,6 +508,57 @@ function CloudSpendingBlock() {
506508
return map
507509
}, [servicesData, servicesLabels])
508510

511+
// Process compared services data
512+
const comparedLabels = useMemo(() => {
513+
const months = new Set<string>()
514+
comparedData.forEach(r => {
515+
const month = r['month']
516+
if (month) months.add(month)
517+
})
518+
return Array.from(months).sort()
519+
}, [comparedData])
520+
521+
const comparisons = useMemo(() => {
522+
const map = new Map<string, { stacks: StackSeries[]; currency: string }>()
523+
// Colors for comparison groups - can be more varied
524+
const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6']
525+
526+
const tmp: Record<string, Record<string, number[]>> = {}
527+
const cur: Record<string, Set<string>> = {}
528+
529+
comparedData.forEach(r => {
530+
const comp = r['comparison']
531+
const group = r['group']
532+
const month = r['month']
533+
if (!comp || !group || !month) return
534+
const idx = comparedLabels.indexOf(month)
535+
if (idx < 0) return
536+
537+
if (!tmp[comp]) tmp[comp] = {}
538+
if (!tmp[comp][group]) tmp[comp][group] = new Array(comparedLabels.length).fill(0)
539+
tmp[comp][group][idx] = parseNumber(r['cost']) ?? 0
540+
541+
const c = (r['currency'] || '').toString().trim()
542+
if (c) {
543+
if (!cur[comp]) cur[comp] = new Set<string>()
544+
cur[comp].add(c)
545+
}
546+
})
547+
548+
Object.keys(tmp).forEach(comp => {
549+
const groups = Object.keys(tmp[comp]).sort()
550+
const stacks = groups.map((group, i) => ({
551+
name: group,
552+
values: tmp[comp][group],
553+
color: colors[i % colors.length]
554+
}))
555+
const set = cur[comp]
556+
const currency = set && set.size === 1 ? Array.from(set)[0] : ''
557+
map.set(comp, { stacks, currency })
558+
})
559+
return map
560+
}, [comparedData, comparedLabels])
561+
509562
return (
510563
<section>
511564
<h2 className="text-xl font-semibold mb-3">{t('cloudSpending.sectionTitle')}</h2>
@@ -533,6 +586,27 @@ function CloudSpendingBlock() {
533586
</CardContent>
534587
</Card>
535588

589+
{/* Compared services: side-by-side comparison */}
590+
{comparisons.size > 0 && Array.from(comparisons.entries()).map(([comp, info]) => (
591+
<Card key={comp}>
592+
<CardHeader>
593+
<CardTitle>{comp}</CardTitle>
594+
</CardHeader>
595+
<CardContent>
596+
<div className="w-full overflow-x-auto">
597+
<StackedBarChart
598+
labels={comparedLabels}
599+
stacks={info.stacks}
600+
width={Math.max(900, comparedLabels.length * 60)}
601+
height={300}
602+
yAxisLabel={info.currency ? `${t('cloudSpending.amount')} (${info.currency})` : t('cloudSpending.amount')}
603+
showLegend
604+
/>
605+
</div>
606+
</CardContent>
607+
</Card>
608+
))}
609+
536610
{/* Service-specific: one chart per service */}
537611
{servicesByService.size > 0 ? (
538612
Array.from(servicesByService.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([service, info]) => (

ui/src/api.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,10 @@ export function useCloudSpendingServices() {
7171
queryFn: () => fetchJSON('/api/cloud_spending/services'),
7272
})
7373
}
74+
75+
export function useCloudSpendingCompared() {
76+
return useQuery<Row[]>({
77+
queryKey: ['cloud_spending_compared'],
78+
queryFn: () => fetchJSON('/api/cloud_spending/compared'),
79+
})
80+
}

0 commit comments

Comments
 (0)