From 6cfe0a81f0992dcd0ddc3ebfba7b5b41e197194a Mon Sep 17 00:00:00 2001 From: Robert Romero Date: Thu, 7 Aug 2025 08:19:17 -0700 Subject: [PATCH] Expand job details in cost breakdown --- src/slurmcostmanager.js | 102 +++++++++++++++++++++++++-- src/slurmdb.py | 26 ++++++- test/unit/billing_summary.test.py | 43 +++++++++++ test/unit/slurmdb_validation.test.py | 28 ++++++++ 4 files changed, 190 insertions(+), 9 deletions(-) diff --git a/src/slurmcostmanager.js b/src/slurmcostmanager.js index 20380c2..dabb36a 100644 --- a/src/slurmcostmanager.js +++ b/src/slurmcostmanager.js @@ -339,6 +339,52 @@ function PiConsumptionChart({ details, width = 300, height = 300, legend = true ); } +function parseTRES(tres) { + const result = { cpu: '', mem: '', node: '', gpu: '', gpuType: '' }; + if (!tres) return result; + tres.split(',').forEach(part => { + const [key, val] = part.split('='); + if (key === 'cpu') result.cpu = val; + else if (key === 'mem') result.mem = val; + else if (key === 'node') result.node = val; + else if (key && key.startsWith('gres/gpu')) { + const pieces = key.split(':'); + result.gpu = val; + if (pieces[1]) result.gpuType = pieces[1]; + } + }); + return result; +} + +function formatReqTres(tres) { + const t = parseTRES(tres); + return `cpu=${t.cpu} mem=${t.mem} node=${t.node} gres/gpu=${t.gpu}`; +} + +function formatAllocTres(tres) { + const t = parseTRES(tres); + const gpu = t.gpu + ? t.gpuType + ? `gres/gpu:(${t.gpuType})=${t.gpu}` + : `gres/gpu=${t.gpu}` + : ''; + return `cpu=${t.cpu} mem=${t.mem} node=${t.node} ${gpu}`.trim(); +} + +function formatElapsed(sec) { + if (typeof sec !== 'number') return ''; + const h = Math.floor(sec / 3600) + .toString() + .padStart(2, '0'); + const m = Math.floor((sec % 3600) / 60) + .toString() + .padStart(2, '0'); + const s = Math.floor(sec % 60) + .toString() + .padStart(2, '0'); + return `${h}:${m}:${s}`; +} + function PaginatedJobTable({ jobs }) { const [sortAsc, setSortAsc] = useState(true); const [page, setPage] = useState(0); @@ -363,12 +409,20 @@ function PaginatedJobTable({ jobs }) { React.createElement( 'tr', null, - React.createElement('th', null, 'Job'), + React.createElement('th', null, 'JobID'), + React.createElement('th', null, 'JobName'), + React.createElement('th', null, 'Partition'), + React.createElement('th', null, 'Start'), + React.createElement('th', null, 'End'), + React.createElement('th', null, 'Elapsed'), + React.createElement('th', null, 'ReqTRES'), + React.createElement('th', null, 'AllocTRES'), + React.createElement('th', null, 'State'), React.createElement('th', null, 'Core Hours'), React.createElement( 'th', { className: 'clickable', onClick: toggleSort }, - '$ cost' + '$ Cost' ) ) ), @@ -380,6 +434,14 @@ function PaginatedJobTable({ jobs }) { 'tr', { key: i }, React.createElement('td', null, j.job), + React.createElement('td', null, j.job_name || ''), + React.createElement('td', null, j.partition || ''), + React.createElement('td', null, j.start || ''), + React.createElement('td', null, j.end || ''), + React.createElement('td', null, formatElapsed(j.elapsed)), + React.createElement('td', null, formatReqTres(j.req_tres)), + React.createElement('td', null, formatAllocTres(j.alloc_tres)), + React.createElement('td', null, j.state || ''), React.createElement('td', null, j.core_hours), React.createElement('td', null, j.cost) ) @@ -617,13 +679,41 @@ function Details({ details, daily, partitions = [], accounts = [], users = [] }) .filter(Boolean); function exportCSV() { - const rows = [['Account', 'Core Hours', 'Cost']]; + const rows = [ + [ + 'Account', + 'User', + 'JobID', + 'JobName', + 'Partition', + 'Start', + 'End', + 'Elapsed', + 'ReqTRES', + 'AllocTRES', + 'State', + 'Core Hours', + 'Cost' + ] + ]; filteredDetails.forEach(d => { - rows.push([d.account, d.core_hours, d.cost]); (d.users || []).forEach(u => { - rows.push([` ${u.user}`, u.core_hours, u.cost]); (u.jobs || []).forEach(j => { - rows.push([` ${j.job}`, j.core_hours, j.cost]); + rows.push([ + d.account, + u.user, + j.job, + j.job_name || '', + j.partition || '', + j.start || '', + j.end || '', + formatElapsed(j.elapsed), + formatReqTres(j.req_tres), + formatAllocTres(j.alloc_tres), + j.state || '', + j.core_hours, + j.cost + ]); }); }); }); diff --git a/src/slurmdb.py b/src/slurmdb.py index 5398768..9beb072 100644 --- a/src/slurmdb.py +++ b/src/slurmdb.py @@ -242,8 +242,9 @@ def fetch_usage_records(self, start_time, end_time): cpu_col = "cpus_req" query = ( - f"SELECT j.id_job AS jobid, j.account, j.partition, a.user AS user_name, j.time_start, j.time_end, " - f"j.tres_alloc, j.{cpu_col} AS cpus_alloc FROM {job_table} AS j " + f"SELECT j.id_job AS jobid, j.name AS job_name, j.account, j.partition, " + f"a.user AS user_name, j.time_start, j.time_end, j.tres_req, j.tres_alloc, " + f"j.{cpu_col} AS cpus_alloc, j.state FROM {job_table} AS j " f"LEFT JOIN {assoc_table} AS a ON j.id_assoc = a.id_assoc " f"WHERE j.time_start >= %s AND j.time_end <= %s" ) @@ -312,7 +313,18 @@ def aggregate_usage(self, start_time, end_time): ) user_entry['core_hours'] += cpus * dur_hours job_entry = user_entry['jobs'].setdefault( - job, {'core_hours': 0.0} + job, + { + 'core_hours': 0.0, + 'job_name': row.get('job_name'), + 'partition': partition, + 'start': start.isoformat(), + 'end': end.isoformat(), + 'elapsed': int((end - start).total_seconds()), + 'req_tres': row.get('tres_req'), + 'alloc_tres': row.get('tres_alloc'), + 'state': row.get('state'), + }, ) job_entry['core_hours'] += cpus * dur_hours return agg, totals @@ -414,6 +426,14 @@ def export_summary(self, start_time, end_time): jobs.append( { 'job': job, + 'job_name': jvals.get('job_name'), + 'partition': jvals.get('partition'), + 'start': jvals.get('start'), + 'end': jvals.get('end'), + 'elapsed': jvals.get('elapsed'), + 'req_tres': jvals.get('req_tres'), + 'alloc_tres': jvals.get('alloc_tres'), + 'state': jvals.get('state'), 'core_hours': round(jvals['core_hours'], 2), 'cost': round(j_cost, 2), } diff --git a/test/unit/billing_summary.test.py b/test/unit/billing_summary.test.py index 5319d75..d6d9a75 100644 --- a/test/unit/billing_summary.test.py +++ b/test/unit/billing_summary.test.py @@ -71,6 +71,49 @@ def test_export_summary_applies_overrides_and_discounts(self): self.assertAlmostEqual(costs['special'], 0.23) self.assertAlmostEqual(summary['summary']['total'], 0.43) + def test_export_summary_preserves_job_details(self): + usage = { + '2024-03': { + 'acct': { + 'core_hours': 1.0, + 'users': { + 'u': { + 'core_hours': 1.0, + 'jobs': { + '123': { + 'core_hours': 1.0, + 'job_name': 'name', + 'partition': 'p1', + 'start': '1970-01-01T00:00:00', + 'end': '1970-01-01T01:00:00', + 'elapsed': 3600, + 'req_tres': 'cpu=1', + 'alloc_tres': 'cpu=1', + 'state': 'COMPLETED', + } + }, + } + }, + } + } + } + with mock.patch.object( + SlurmDB, + 'aggregate_usage', + return_value=(usage, {'daily': {}, 'monthly': {}, 'yearly': {}}), + ), mock.patch.object(SlurmDB, 'fetch_invoices', return_value=[]): + db = SlurmDB() + summary = db.export_summary('2024-03-01', '2024-03-31') + job = summary['details'][0]['users'][0]['jobs'][0] + self.assertEqual(job['job_name'], 'name') + self.assertEqual(job['partition'], 'p1') + self.assertEqual(job['start'], '1970-01-01T00:00:00') + self.assertEqual(job['end'], '1970-01-01T01:00:00') + self.assertEqual(job['elapsed'], 3600) + self.assertEqual(job['req_tres'], 'cpu=1') + self.assertEqual(job['alloc_tres'], 'cpu=1') + self.assertEqual(job['state'], 'COMPLETED') + def test_export_summary_negative_rate(self): usage = { '2023-10': { diff --git a/test/unit/slurmdb_validation.test.py b/test/unit/slurmdb_validation.test.py index 8b181ba..5326525 100644 --- a/test/unit/slurmdb_validation.test.py +++ b/test/unit/slurmdb_validation.test.py @@ -66,6 +66,34 @@ def test_cpus_alloc_fallback(self): agg, totals = db.aggregate_usage(0, 3600) self.assertAlmostEqual(agg['1970-01']['acct']['core_hours'], 2.0) + def test_aggregate_usage_includes_job_details(self): + db = SlurmDB() + db.fetch_usage_records = lambda start, end: [ + { + 'jobid': 123, + 'job_name': 'jobname', + 'account': 'acct', + 'user_name': 'user', + 'partition': 'p1', + 'time_start': 0, + 'time_end': 3600, + 'tres_req': 'cpu=1,mem=1G', + 'tres_alloc': 'cpu=1,mem=1G,gres/gpu:tesla=1', + 'cpus_alloc': 1, + 'state': 'COMPLETED', + } + ] + agg, _ = db.aggregate_usage(0, 3600) + job = agg['1970-01']['acct']['users']['user']['jobs']['123'] + self.assertEqual(job['job_name'], 'jobname') + self.assertEqual(job['partition'], 'p1') + self.assertEqual(job['start'], '1970-01-01T00:00:00') + self.assertEqual(job['end'], '1970-01-01T01:00:00') + self.assertEqual(job['elapsed'], 3600) + self.assertEqual(job['req_tres'], 'cpu=1,mem=1G') + self.assertEqual(job['alloc_tres'], 'cpu=1,mem=1G,gres/gpu:tesla=1') + self.assertEqual(job['state'], 'COMPLETED') + def test_close_closes_connection(self): class FakeConn: def __init__(self):