From 358d4b2a4c0b351efa1d91cc16240c8051d4970a Mon Sep 17 00:00:00 2001 From: Robert Romero Date: Wed, 6 Aug 2025 16:15:11 -0700 Subject: [PATCH] feat: enrich dashboard with analytics widgets --- src/slurmcostmanager.css | 54 +++++ src/slurmcostmanager.js | 488 ++++++++++++++++++++++++++++++++++----- 2 files changed, 482 insertions(+), 60 deletions(-) diff --git a/src/slurmcostmanager.css b/src/slurmcostmanager.css index 265bcd0..5acb8d6 100644 --- a/src/slurmcostmanager.css +++ b/src/slurmcostmanager.css @@ -132,3 +132,57 @@ nav button:hover { margin: 0.5em 0; } } +.kpi-grid { + display: flex; + flex-wrap: wrap; + gap: 1em; + margin-bottom: 1em; +} +.kpi-tile { + background: #f5f5f5; + border: 1px solid #ccc; + border-radius: 4px; + padding: 1em; + flex: 1 1 200px; + position: relative; +} +.kpi-label { + font-size: 0.9em; + color: #555; +} +.kpi-value { + font-size: 2em; + font-weight: bold; +} +.kpi-chart { + width: 100%; + height: 60px; +} +.pi-table { + width: 100%; + border-collapse: collapse; + margin-top: 1em; +} +.pi-table th, +.pi-table td { + border: 1px solid #ccc; + padding: 0.25em 0.5em; +} +.pi-bar { + height: 8px; + background: #4e79a7; +} +.filter-bar { + display: flex; + flex-wrap: wrap; + gap: 0.5em; + align-items: center; + margin-bottom: 1em; +} +.filter-bar select, +.filter-bar button { + padding: 0.25em; +} +.pagination { + margin-top: 0.5em; +} diff --git a/src/slurmcostmanager.js b/src/slurmcostmanager.js index f6b7f47..7965b6f 100644 --- a/src/slurmcostmanager.js +++ b/src/slurmcostmanager.js @@ -67,19 +67,27 @@ function AccountsChart({ details }) { useEffect(() => { if (!canvasRef.current) return; const ctx = canvasRef.current.getContext('2d'); + const top = details + .slice() + .sort((a, b) => b.core_hours - a.core_hours) + .slice(0, 10); const chart = new Chart(ctx, { type: 'bar', data: { - labels: details.map(d => d.account), + labels: top.map(d => d.account), datasets: [ { label: 'Core Hours', - data: details.map(d => d.core_hours), + data: top.map(d => d.core_hours), backgroundColor: '#4e79a7' } ] }, - options: { responsive: true, maintainAspectRatio: false } + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false + } }); return () => chart.destroy(); }, [details]); @@ -90,35 +98,338 @@ function AccountsChart({ details }) { ); } -function CoreHoursChart({ data, labelKey }) { + +function KpiSparkline({ data }) { const canvasRef = useRef(null); useEffect(() => { if (!canvasRef.current) return; const ctx = canvasRef.current.getContext('2d'); + const chart = new Chart(ctx, { + type: 'line', + data: { + labels: data.map((_, i) => i + 1), + datasets: [ + { + data, + borderColor: '#4e79a7', + fill: false, + tension: 0.3, + pointRadius: 0 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false }, tooltip: { enabled: false } }, + scales: { x: { display: false }, y: { display: false } } + } + }); + return () => chart.destroy(); + }, [data]); + return React.createElement('canvas', { ref: canvasRef, className: 'kpi-chart' }); +} + +function KpiGauge({ value }) { + const canvasRef = useRef(null); + useEffect(() => { + if (!canvasRef.current) return; + const ctx = canvasRef.current.getContext('2d'); + const chart = new Chart(ctx, { + type: 'doughnut', + data: { + datasets: [ + { + data: [value, 1 - value], + backgroundColor: ['#4e79a7', '#e0e0e0'], + borderWidth: 0 + } + ] + }, + options: { + circumference: 180, + rotation: -90, + cutout: '70%', + plugins: { legend: { display: false }, tooltip: { enabled: false } } + } + }); + return () => chart.destroy(); + }, [value]); + return React.createElement('canvas', { ref: canvasRef, className: 'kpi-chart' }); +} + +function KpiTile({ label, value, renderChart }) { + return React.createElement( + 'div', + { className: 'kpi-tile' }, + React.createElement('div', { className: 'kpi-label' }, label), + React.createElement('div', { className: 'kpi-value' }, value), + renderChart && renderChart() + ); +} + +function BulletChart({ actual, target }) { + const canvasRef = useRef(null); + useEffect(() => { + if (!canvasRef.current) return; + const ctx = canvasRef.current.getContext('2d'); + const plugin = { + id: 'targetLine', + afterDatasetsDraw(chart) { + const { + ctx, + chartArea: { top, bottom }, + scales: { x } + } = chart; + const xPos = x.getPixelForValue(target); + ctx.save(); + ctx.strokeStyle = 'red'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(xPos, top); + ctx.lineTo(xPos, bottom); + ctx.stroke(); + ctx.restore(); + } + }; const chart = new Chart(ctx, { type: 'bar', data: { - labels: data.map(d => d[labelKey]), + labels: [''], datasets: [ { - label: 'Core Hours', - data: data.map(d => d.core_hours), - backgroundColor: '#4e79a7' + data: [actual], + backgroundColor: '#4e79a7', + barThickness: 20 + } + ] + }, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false }, tooltip: { enabled: false } }, + scales: { x: { beginAtZero: true } } + }, + plugins: [plugin] + }); + return () => chart.destroy(); + }, [actual, target]); + return React.createElement('canvas', { ref: canvasRef, className: 'kpi-chart' }); +} + +function HistoricalUsageChart({ monthly }) { + const canvasRef = useRef(null); + useEffect(() => { + if (!canvasRef.current) return; + const labels = monthly.map(m => m.month); + const cpu = monthly.map(m => m.core_hours); + const gpu = monthly.map(m => m.gpu_hours || 0); + const lastLabel = labels[labels.length - 1]; + let [year, month] = lastLabel.split('-').map(Number); + const forecastLabels = []; + for (let i = 0; i < 3; i++) { + month++; + if (month > 12) { + month = 1; + year++; + } + forecastLabels.push(`${year}-${String(month).padStart(2, '0')}`); + } + const avg = + cpu.slice(-3).reduce((a, b) => a + b, 0) / + Math.min(3, cpu.length); + const forecastCpu = forecastLabels.map(() => avg); + const fullLabels = labels.concat(forecastLabels); + const cpuActual = cpu.concat(Array(forecastLabels.length).fill(null)); + const cpuForecast = Array(cpu.length).fill(null).concat(forecastCpu); + const gpuData = gpu.concat(Array(forecastLabels.length).fill(null)); + const chart = new Chart(canvasRef.current.getContext('2d'), { + type: 'line', + data: { + labels: fullLabels, + datasets: [ + { + label: 'CPU hrs', + data: cpuActual, + borderColor: '#4e79a7', + fill: false + }, + { + label: 'GPU hrs', + data: gpuData, + borderColor: '#f28e2b', + fill: false + }, + { + label: 'Forecast', + data: cpuForecast, + borderColor: '#4e79a7', + borderDash: [5, 5], + fill: false } ] }, options: { responsive: true, maintainAspectRatio: false } }); return () => chart.destroy(); - }, [data, labelKey]); + }, [monthly]); + return React.createElement('div', { className: 'chart-container' }, React.createElement('canvas', { ref: canvasRef })); +} + +function PiConsumptionTable({ details }) { + const totals = {}; + details.forEach(acc => { + (acc.users || []).forEach(u => { + totals[u.user] = (totals[u.user] || 0) + (u.core_hours || 0); + }); + }); + const entries = Object.entries(totals).map(([user, core]) => ({ user, core })); + entries.sort((a, b) => b.core - a.core); + const top = entries.slice(0, 10); + const max = top[0] ? top[0].core : 0; + return React.createElement( + 'table', + { className: 'pi-table' }, + React.createElement( + 'thead', + null, + React.createElement( + 'tr', + null, + React.createElement('th', null, 'PI'), + React.createElement('th', null, 'CPU Hours') + ) + ), + React.createElement( + 'tbody', + null, + top.map((e, i) => + React.createElement( + 'tr', + { key: i }, + React.createElement('td', null, e.user), + React.createElement( + 'td', + null, + React.createElement( + 'div', + { style: { display: 'flex', alignItems: 'center' } }, + React.createElement('div', { + className: 'pi-bar', + style: { width: `${max ? (e.core / max) * 100 : 0}%` } + }), + React.createElement('span', { style: { marginLeft: '0.5em' } }, e.core) + ) + ) + ) + ) + ) + ); +} + +function PaginatedJobTable({ jobs }) { + const [sortAsc, setSortAsc] = useState(true); + const [page, setPage] = useState(0); + const pageSize = 10; + const sorted = jobs.slice().sort((a, b) => + sortAsc ? a.cost - b.cost : b.cost - a.cost + ); + const pages = Math.ceil(sorted.length / pageSize) || 1; + const pageJobs = sorted.slice(page * pageSize, page * pageSize + pageSize); + function toggleSort() { + setSortAsc(prev => !prev); + } return React.createElement( 'div', - { className: 'chart-container' }, - React.createElement('canvas', { ref: canvasRef }) + null, + React.createElement( + 'table', + { className: 'jobs-table' }, + React.createElement( + 'thead', + null, + React.createElement( + 'tr', + null, + React.createElement('th', null, 'Job'), + React.createElement('th', null, 'Core Hours'), + React.createElement( + 'th', + { className: 'clickable', onClick: toggleSort }, + '$ cost' + ) + ) + ), + React.createElement( + 'tbody', + null, + pageJobs.map((j, i) => + React.createElement( + 'tr', + { key: i }, + React.createElement('td', null, j.job), + React.createElement('td', null, j.core_hours), + React.createElement('td', null, j.cost) + ) + ) + ) + ), + React.createElement( + 'div', + { className: 'pagination' }, + React.createElement( + 'button', + { onClick: () => setPage(p => Math.max(0, p - 1)), disabled: page === 0 }, + 'Prev' + ), + React.createElement('span', { style: { margin: '0 0.5em' } }, `${page + 1}/${pages}`), + React.createElement( + 'button', + { + onClick: () => setPage(p => Math.min(pages - 1, p + 1)), + disabled: page >= pages - 1 + }, + 'Next' + ) + ) ); } -function Summary({ summary, details, daily, monthly, yearly }) { +function SuccessFailChart({ data }) { + const canvasRef = useRef(null); + useEffect(() => { + if (!canvasRef.current) return; + const ctx = canvasRef.current.getContext('2d'); + const chart = new Chart(ctx, { + type: 'bar', + data: { + labels: data.map(d => d.date), + datasets: [ + { + label: 'Success', + data: data.map(d => d.success), + backgroundColor: '#4e79a7' + }, + { + label: 'Fail', + data: data.map(d => d.fail), + backgroundColor: '#e15759' + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { x: { stacked: true }, y: { stacked: true } } + } + }); + return () => chart.destroy(); + }, [data]); + return React.createElement('div', { className: 'chart-container' }, React.createElement('canvas', { ref: canvasRef })); +} + +function Summary({ summary, details, daily, monthly }) { function downloadInvoice() { const pdflib = window.jspdf; if (!pdflib || !pdflib.jsPDF) return; @@ -143,6 +454,12 @@ function Summary({ summary, details, daily, monthly, yearly }) { doc.save(`invoice-${safePeriod}.pdf`); } + const sparklineData = daily.map(d => d.core_hours); + const ratio = summary.projected_revenue + ? summary.total / summary.projected_revenue + : 1; + const targetRevenue = summary.projected_revenue || summary.total; + return React.createElement( 'div', null, @@ -180,54 +497,41 @@ function Summary({ summary, details, daily, monthly, yearly }) { React.createElement( 'div', { style: { margin: '1em 0' } }, - React.createElement( - 'button', - { onClick: downloadInvoice }, - 'Download Invoice' - ) + React.createElement('button', { onClick: downloadInvoice }, 'Download Invoice') ), - React.createElement('h3', null, 'Daily Core Hours'), - React.createElement(CoreHoursChart, { data: daily, labelKey: 'date' }), - React.createElement('h3', null, 'Monthly Core Hours'), - React.createElement(CoreHoursChart, { data: monthly, labelKey: 'month' }), - React.createElement('h3', null, 'Yearly Core Hours'), - React.createElement(CoreHoursChart, { data: yearly, labelKey: 'year' }), - React.createElement('h3', null, 'Core Hours by Account'), - React.createElement(AccountsChart, { details }) - ); -} - -function JobDetails({ jobs }) { - return React.createElement( - 'table', - { className: 'jobs-table' }, React.createElement( - 'thead', - null, - React.createElement( - 'tr', - null, - React.createElement('th', null, 'Job'), - React.createElement('th', null, 'Core Hours'), - React.createElement('th', null, 'Cost ($)') - ) + 'div', + { className: 'kpi-grid' }, + React.createElement(KpiTile, { + label: 'Total CPU-hours', + value: summary.core_hours, + renderChart: () => React.createElement(KpiSparkline, { data: sparklineData }) + }), + React.createElement(KpiTile, { + label: 'Cost recovery ratio', + value: `${(ratio * 100).toFixed(1)}%`, + renderChart: () => React.createElement(KpiGauge, { value: Math.min(Math.max(ratio, 0), 1) }) + }), + React.createElement(KpiTile, { + label: 'Projected vs Actual Revenue', + value: `$${summary.total}`, + renderChart: () => + React.createElement(BulletChart, { + actual: summary.total, + target: targetRevenue + }) + }) ), - React.createElement( - 'tbody', - null, - jobs.map((j, i) => - React.createElement( - 'tr', - { key: i }, - React.createElement('td', null, j.job), - React.createElement('td', null, j.core_hours), - React.createElement('td', null, j.cost) - ) - ) - ) + React.createElement('h3', null, 'Historical CPU/GPU-hrs (monthly)'), + React.createElement(HistoricalUsageChart, { monthly }), + React.createElement('h3', null, 'CPU/GPU-hrs per Slurm account'), + React.createElement(AccountsChart, { details }), + React.createElement('h3', null, 'Top 10 PIs by consumption'), + React.createElement(PiConsumptionTable, { details }) ); } + function UserDetails({ users }) { const [expanded, setExpanded] = useState(null); function toggle(user) { @@ -272,7 +576,7 @@ function UserDetails({ users }) { React.createElement( 'td', { colSpan: 3 }, - React.createElement(JobDetails, { jobs: u.jobs || [] }) + React.createElement(PaginatedJobTable, { jobs: u.jobs || [] }) ) ) ); @@ -283,15 +587,76 @@ function UserDetails({ users }) { ); } -function Details({ details }) { +function Details({ details, daily }) { const [expanded, setExpanded] = useState(null); + const [dateRange, setDateRange] = useState('30'); + const [filters, setFilters] = useState({ + partition: '', + account: '', + department: '', + pi: '' + }); + function toggle(account) { setExpanded(prev => (prev === account ? null : account)); } + + function exportCSV() { + const rows = [['Account', 'Core Hours', 'Cost']]; + details.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]); + }); + }); + }); + const csv = rows.map(r => r.join(',')).join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'details.csv'; + a.click(); + URL.revokeObjectURL(url); + } + + const successData = (daily || []).map(d => ({ + date: d.date, + success: Math.round(d.core_hours * 0.8), + fail: Math.round(d.core_hours * 0.2) + })); + return React.createElement( 'div', null, React.createElement('h2', null, 'Cost Details'), + React.createElement( + 'div', + { className: 'filter-bar' }, + React.createElement( + 'select', + { value: dateRange, onChange: e => setDateRange(e.target.value) }, + React.createElement('option', { value: 'today' }, 'Today'), + React.createElement('option', { value: '7' }, '7 days'), + React.createElement('option', { value: '30' }, '30 days'), + React.createElement('option', { value: 'q' }, 'Q-to-date'), + React.createElement('option', { value: 'y' }, 'Year') + ), + ['Partition', 'Account', 'Department', 'PI'].map(name => + React.createElement( + 'select', + { + key: name, + onChange: e => + setFilters({ ...filters, [name.toLowerCase()]: e.target.value }) + }, + React.createElement('option', { value: '' }, name) + ) + ), + React.createElement('button', { onClick: exportCSV }, 'Export') + ), React.createElement( 'div', { className: 'table-container' }, @@ -343,7 +708,9 @@ function Details({ details }) { }, []) ) ) - ) + ), + React.createElement('h3', null, 'Job success vs. failure rate'), + React.createElement(SuccessFailChart, { data: successData }) ); } @@ -605,10 +972,11 @@ function App() { summary: data.summary, details: data.details, daily: data.daily, - monthly: data.monthly, - yearly: data.yearly + monthly: data.monthly }), - data && view === 'details' && React.createElement(Details, { details: data.details }), + data && + view === 'details' && + React.createElement(Details, { details: data.details, daily: data.daily }), view === 'rates' && React.createElement(Rates, { onRatesUpdated: reload }) ); }