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
102 changes: 96 additions & 6 deletions src/slurmcostmanager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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'
)
)
),
Expand All @@ -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)
)
Expand Down Expand Up @@ -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
]);
});
});
});
Expand Down
26 changes: 23 additions & 3 deletions src/slurmdb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
}
Expand Down
43 changes: 43 additions & 0 deletions test/unit/billing_summary.test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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': {
Expand Down
28 changes: 28 additions & 0 deletions test/unit/slurmdb_validation.test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading