Skip to content

Commit d4ea2aa

Browse files
Merge pull request #96 from NessieCanCode/add-detailed-cost-information-in-details-tab
Add detailed job info to Cost Details view
2 parents 5e141d6 + 6cfe0a8 commit d4ea2aa

File tree

4 files changed

+190
-9
lines changed

4 files changed

+190
-9
lines changed

src/slurmcostmanager.js

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,52 @@ function PiConsumptionChart({ details, width = 300, height = 300, legend = true
339339
);
340340
}
341341

342+
function parseTRES(tres) {
343+
const result = { cpu: '', mem: '', node: '', gpu: '', gpuType: '' };
344+
if (!tres) return result;
345+
tres.split(',').forEach(part => {
346+
const [key, val] = part.split('=');
347+
if (key === 'cpu') result.cpu = val;
348+
else if (key === 'mem') result.mem = val;
349+
else if (key === 'node') result.node = val;
350+
else if (key && key.startsWith('gres/gpu')) {
351+
const pieces = key.split(':');
352+
result.gpu = val;
353+
if (pieces[1]) result.gpuType = pieces[1];
354+
}
355+
});
356+
return result;
357+
}
358+
359+
function formatReqTres(tres) {
360+
const t = parseTRES(tres);
361+
return `cpu=${t.cpu} mem=${t.mem} node=${t.node} gres/gpu=${t.gpu}`;
362+
}
363+
364+
function formatAllocTres(tres) {
365+
const t = parseTRES(tres);
366+
const gpu = t.gpu
367+
? t.gpuType
368+
? `gres/gpu:(${t.gpuType})=${t.gpu}`
369+
: `gres/gpu=${t.gpu}`
370+
: '';
371+
return `cpu=${t.cpu} mem=${t.mem} node=${t.node} ${gpu}`.trim();
372+
}
373+
374+
function formatElapsed(sec) {
375+
if (typeof sec !== 'number') return '';
376+
const h = Math.floor(sec / 3600)
377+
.toString()
378+
.padStart(2, '0');
379+
const m = Math.floor((sec % 3600) / 60)
380+
.toString()
381+
.padStart(2, '0');
382+
const s = Math.floor(sec % 60)
383+
.toString()
384+
.padStart(2, '0');
385+
return `${h}:${m}:${s}`;
386+
}
387+
342388
function PaginatedJobTable({ jobs }) {
343389
const [sortAsc, setSortAsc] = useState(true);
344390
const [page, setPage] = useState(0);
@@ -363,12 +409,20 @@ function PaginatedJobTable({ jobs }) {
363409
React.createElement(
364410
'tr',
365411
null,
366-
React.createElement('th', null, 'Job'),
412+
React.createElement('th', null, 'JobID'),
413+
React.createElement('th', null, 'JobName'),
414+
React.createElement('th', null, 'Partition'),
415+
React.createElement('th', null, 'Start'),
416+
React.createElement('th', null, 'End'),
417+
React.createElement('th', null, 'Elapsed'),
418+
React.createElement('th', null, 'ReqTRES'),
419+
React.createElement('th', null, 'AllocTRES'),
420+
React.createElement('th', null, 'State'),
367421
React.createElement('th', null, 'Core Hours'),
368422
React.createElement(
369423
'th',
370424
{ className: 'clickable', onClick: toggleSort },
371-
'$ cost'
425+
'$ Cost'
372426
)
373427
)
374428
),
@@ -380,6 +434,14 @@ function PaginatedJobTable({ jobs }) {
380434
'tr',
381435
{ key: i },
382436
React.createElement('td', null, j.job),
437+
React.createElement('td', null, j.job_name || ''),
438+
React.createElement('td', null, j.partition || ''),
439+
React.createElement('td', null, j.start || ''),
440+
React.createElement('td', null, j.end || ''),
441+
React.createElement('td', null, formatElapsed(j.elapsed)),
442+
React.createElement('td', null, formatReqTres(j.req_tres)),
443+
React.createElement('td', null, formatAllocTres(j.alloc_tres)),
444+
React.createElement('td', null, j.state || ''),
383445
React.createElement('td', null, j.core_hours),
384446
React.createElement('td', null, j.cost)
385447
)
@@ -617,13 +679,41 @@ function Details({ details, daily, partitions = [], accounts = [], users = [] })
617679
.filter(Boolean);
618680

619681
function exportCSV() {
620-
const rows = [['Account', 'Core Hours', 'Cost']];
682+
const rows = [
683+
[
684+
'Account',
685+
'User',
686+
'JobID',
687+
'JobName',
688+
'Partition',
689+
'Start',
690+
'End',
691+
'Elapsed',
692+
'ReqTRES',
693+
'AllocTRES',
694+
'State',
695+
'Core Hours',
696+
'Cost'
697+
]
698+
];
621699
filteredDetails.forEach(d => {
622-
rows.push([d.account, d.core_hours, d.cost]);
623700
(d.users || []).forEach(u => {
624-
rows.push([` ${u.user}`, u.core_hours, u.cost]);
625701
(u.jobs || []).forEach(j => {
626-
rows.push([` ${j.job}`, j.core_hours, j.cost]);
702+
rows.push([
703+
d.account,
704+
u.user,
705+
j.job,
706+
j.job_name || '',
707+
j.partition || '',
708+
j.start || '',
709+
j.end || '',
710+
formatElapsed(j.elapsed),
711+
formatReqTres(j.req_tres),
712+
formatAllocTres(j.alloc_tres),
713+
j.state || '',
714+
j.core_hours,
715+
j.cost
716+
]);
627717
});
628718
});
629719
});

src/slurmdb.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,8 +242,9 @@ def fetch_usage_records(self, start_time, end_time):
242242
cpu_col = "cpus_req"
243243

244244
query = (
245-
f"SELECT j.id_job AS jobid, j.account, j.partition, a.user AS user_name, j.time_start, j.time_end, "
246-
f"j.tres_alloc, j.{cpu_col} AS cpus_alloc FROM {job_table} AS j "
245+
f"SELECT j.id_job AS jobid, j.name AS job_name, j.account, j.partition, "
246+
f"a.user AS user_name, j.time_start, j.time_end, j.tres_req, j.tres_alloc, "
247+
f"j.{cpu_col} AS cpus_alloc, j.state FROM {job_table} AS j "
247248
f"LEFT JOIN {assoc_table} AS a ON j.id_assoc = a.id_assoc "
248249
f"WHERE j.time_start >= %s AND j.time_end <= %s"
249250
)
@@ -312,7 +313,18 @@ def aggregate_usage(self, start_time, end_time):
312313
)
313314
user_entry['core_hours'] += cpus * dur_hours
314315
job_entry = user_entry['jobs'].setdefault(
315-
job, {'core_hours': 0.0}
316+
job,
317+
{
318+
'core_hours': 0.0,
319+
'job_name': row.get('job_name'),
320+
'partition': partition,
321+
'start': start.isoformat(),
322+
'end': end.isoformat(),
323+
'elapsed': int((end - start).total_seconds()),
324+
'req_tres': row.get('tres_req'),
325+
'alloc_tres': row.get('tres_alloc'),
326+
'state': row.get('state'),
327+
},
316328
)
317329
job_entry['core_hours'] += cpus * dur_hours
318330
return agg, totals
@@ -414,6 +426,14 @@ def export_summary(self, start_time, end_time):
414426
jobs.append(
415427
{
416428
'job': job,
429+
'job_name': jvals.get('job_name'),
430+
'partition': jvals.get('partition'),
431+
'start': jvals.get('start'),
432+
'end': jvals.get('end'),
433+
'elapsed': jvals.get('elapsed'),
434+
'req_tres': jvals.get('req_tres'),
435+
'alloc_tres': jvals.get('alloc_tres'),
436+
'state': jvals.get('state'),
417437
'core_hours': round(jvals['core_hours'], 2),
418438
'cost': round(j_cost, 2),
419439
}

test/unit/billing_summary.test.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,49 @@ def test_export_summary_applies_overrides_and_discounts(self):
7171
self.assertAlmostEqual(costs['special'], 0.23)
7272
self.assertAlmostEqual(summary['summary']['total'], 0.43)
7373

74+
def test_export_summary_preserves_job_details(self):
75+
usage = {
76+
'2024-03': {
77+
'acct': {
78+
'core_hours': 1.0,
79+
'users': {
80+
'u': {
81+
'core_hours': 1.0,
82+
'jobs': {
83+
'123': {
84+
'core_hours': 1.0,
85+
'job_name': 'name',
86+
'partition': 'p1',
87+
'start': '1970-01-01T00:00:00',
88+
'end': '1970-01-01T01:00:00',
89+
'elapsed': 3600,
90+
'req_tres': 'cpu=1',
91+
'alloc_tres': 'cpu=1',
92+
'state': 'COMPLETED',
93+
}
94+
},
95+
}
96+
},
97+
}
98+
}
99+
}
100+
with mock.patch.object(
101+
SlurmDB,
102+
'aggregate_usage',
103+
return_value=(usage, {'daily': {}, 'monthly': {}, 'yearly': {}}),
104+
), mock.patch.object(SlurmDB, 'fetch_invoices', return_value=[]):
105+
db = SlurmDB()
106+
summary = db.export_summary('2024-03-01', '2024-03-31')
107+
job = summary['details'][0]['users'][0]['jobs'][0]
108+
self.assertEqual(job['job_name'], 'name')
109+
self.assertEqual(job['partition'], 'p1')
110+
self.assertEqual(job['start'], '1970-01-01T00:00:00')
111+
self.assertEqual(job['end'], '1970-01-01T01:00:00')
112+
self.assertEqual(job['elapsed'], 3600)
113+
self.assertEqual(job['req_tres'], 'cpu=1')
114+
self.assertEqual(job['alloc_tres'], 'cpu=1')
115+
self.assertEqual(job['state'], 'COMPLETED')
116+
74117
def test_export_summary_negative_rate(self):
75118
usage = {
76119
'2023-10': {

test/unit/slurmdb_validation.test.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,34 @@ def test_cpus_alloc_fallback(self):
6666
agg, totals = db.aggregate_usage(0, 3600)
6767
self.assertAlmostEqual(agg['1970-01']['acct']['core_hours'], 2.0)
6868

69+
def test_aggregate_usage_includes_job_details(self):
70+
db = SlurmDB()
71+
db.fetch_usage_records = lambda start, end: [
72+
{
73+
'jobid': 123,
74+
'job_name': 'jobname',
75+
'account': 'acct',
76+
'user_name': 'user',
77+
'partition': 'p1',
78+
'time_start': 0,
79+
'time_end': 3600,
80+
'tres_req': 'cpu=1,mem=1G',
81+
'tres_alloc': 'cpu=1,mem=1G,gres/gpu:tesla=1',
82+
'cpus_alloc': 1,
83+
'state': 'COMPLETED',
84+
}
85+
]
86+
agg, _ = db.aggregate_usage(0, 3600)
87+
job = agg['1970-01']['acct']['users']['user']['jobs']['123']
88+
self.assertEqual(job['job_name'], 'jobname')
89+
self.assertEqual(job['partition'], 'p1')
90+
self.assertEqual(job['start'], '1970-01-01T00:00:00')
91+
self.assertEqual(job['end'], '1970-01-01T01:00:00')
92+
self.assertEqual(job['elapsed'], 3600)
93+
self.assertEqual(job['req_tres'], 'cpu=1,mem=1G')
94+
self.assertEqual(job['alloc_tres'], 'cpu=1,mem=1G,gres/gpu:tesla=1')
95+
self.assertEqual(job['state'], 'COMPLETED')
96+
6997
def test_close_closes_connection(self):
7098
class FakeConn:
7199
def __init__(self):

0 commit comments

Comments
 (0)