feat: connect alpine logs and usage to admin api
This commit is contained in:
+259
-237
@@ -1,270 +1,292 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
// ── Period datasets ───────────────────────────────────────────────────────
|
||||
|
||||
var CHART_DATA = {
|
||||
'7d': [
|
||||
{d:'Mon', s:3838, e:18}, {d:'Tue', s:4242, e:32}, {d:'Wed', s:3988, e:28},
|
||||
{d:'Thu', s:4147, e:45}, {d:'Fri', s:3810, e:22}, {d:'Sat', s:2915, e:15},
|
||||
{d:'Sun', s:1569, e:8}
|
||||
],
|
||||
'30d': [
|
||||
{d:'Wk 1', s:24500, e:120}, {d:'Wk 2', s:26800, e:145},
|
||||
{d:'Wk 3', s:25100, e:98}, {d:'Wk 4', s:27400, e:178}
|
||||
],
|
||||
'90d': [
|
||||
{d:'Jan', s:92000, e:420}, {d:'Feb', s:108000, e:510}, {d:'Mar', s:98400, e:458}
|
||||
],
|
||||
'this_month': [
|
||||
{d:'Wk 1', s:24500, e:120}, {d:'Wk 2', s:26800, e:145},
|
||||
{d:'Wk 3', s:25100, e:98}, {d:'Wk 4', s:27400, e:178}
|
||||
]
|
||||
var state = {
|
||||
period: '7d',
|
||||
workspaceId: null,
|
||||
usage: null,
|
||||
loading: false,
|
||||
loadError: '',
|
||||
};
|
||||
|
||||
// Base 7-day operation data; other periods scale from this.
|
||||
var OPS_7D = [
|
||||
{ name: 'create_crm_lead', display: 'Create CRM Lead', proto: 'REST', calls: 9840, errors: 42, p50: 138, p95: 390, p99: 820 },
|
||||
{ name: 'search_products', display: 'Search Products', proto: 'REST', calls: 7123, errors: 8, p50: 91, p95: 210, p99: 440 },
|
||||
{ name: 'fetch_invoice', display: 'Fetch Invoice', proto: 'REST', calls: 5210, errors: 214, p50: 104, p95: 540, p99: 8200 },
|
||||
{ name: 'update_contact', display: 'Update Contact', proto: 'REST', calls: 3882, errors: 28, p50: 192, p95: 460, p99: 910 },
|
||||
{ name: 'send_email', display: 'Send Email', proto: 'REST', calls: 1440, errors: 96, p50: 310, p95: 2100, p99: 30100 },
|
||||
{ name: 'list_orders', display: 'List Orders (GraphQL)', proto: 'GraphQL', calls: 670, errors: 2, p50: 66, p95: 180, p99: 320 },
|
||||
{ name: 'delete_record', display: 'Delete Record', proto: 'REST', calls: 176, errors: 6, p50: 285, p95: 690, p99: 1400 }
|
||||
];
|
||||
var periodSelect = document.getElementById('period');
|
||||
var exportBtn = document.querySelector('.page-header-actions .btn-secondary');
|
||||
var chartWrap = document.getElementById('chart-bars');
|
||||
var tableBody = document.getElementById('usage-tbody');
|
||||
var chartTemplate = document.getElementById('tmpl-chart-bar');
|
||||
var rowTemplate = document.getElementById('tmpl-usage-row');
|
||||
var subtitle = document.querySelector('.section-card-subtitle');
|
||||
var statCards = document.querySelectorAll('.stats-grid .stat-card');
|
||||
|
||||
function scaleOps(factor) {
|
||||
return OPS_7D.map(function (op) {
|
||||
return {
|
||||
name: op.name,
|
||||
display: op.display,
|
||||
proto: op.proto,
|
||||
calls: Math.round(op.calls * factor),
|
||||
errors: Math.round(op.errors * factor),
|
||||
p50: op.p50,
|
||||
p95: op.p95,
|
||||
p99: op.p99
|
||||
};
|
||||
});
|
||||
function currentWorkspaceId() {
|
||||
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
return workspace ? workspace.id : null;
|
||||
}
|
||||
|
||||
var OPS_DATA = {
|
||||
'7d': OPS_7D,
|
||||
'30d': scaleOps(4.2),
|
||||
'90d': scaleOps(12),
|
||||
'this_month': scaleOps(4.2)
|
||||
};
|
||||
function formatCount(value) {
|
||||
return Number(value || 0).toLocaleString();
|
||||
}
|
||||
|
||||
// ── Stat card data ────────────────────────────────────────────────────────
|
||||
|
||||
var STATS = {
|
||||
'7d': {
|
||||
invocations: '28,341',
|
||||
invDelta: { dir: 'up', text: '+12% vs prior period' },
|
||||
success: '98.6%',
|
||||
sucDelta: { dir: 'up', text: '+0.3pp vs prior period' },
|
||||
p50: '124ms',
|
||||
p50Delta: { dir: 'up', text: '-18ms vs prior period' },
|
||||
p99: '2.1s',
|
||||
p99Delta: { dir: 'down', text: '+340ms vs prior period' }
|
||||
},
|
||||
'30d': {
|
||||
invocations: '118,623',
|
||||
invDelta: { dir: 'up', text: '+8% vs prior period' },
|
||||
success: '98.4%',
|
||||
sucDelta: { dir: 'down', text: '-0.2pp vs prior period' },
|
||||
p50: '128ms',
|
||||
p50Delta: { dir: 'down', text: '+4ms vs prior period' },
|
||||
p99: '2.3s',
|
||||
p99Delta: { dir: 'down', text: '+200ms vs prior period' }
|
||||
},
|
||||
'90d': {
|
||||
invocations: '342,804',
|
||||
invDelta: { dir: 'up', text: '+15% vs prior period' },
|
||||
success: '98.7%',
|
||||
sucDelta: { dir: 'up', text: '+0.1pp vs prior period' },
|
||||
p50: '121ms',
|
||||
p50Delta: { dir: 'up', text: '-7ms vs prior period' },
|
||||
p99: '2.0s',
|
||||
p99Delta: { dir: 'up', text: '-100ms vs prior period' }
|
||||
},
|
||||
'this_month': {
|
||||
invocations: '118,623',
|
||||
invDelta: { dir: 'up', text: '+8% vs prior period' },
|
||||
success: '98.4%',
|
||||
sucDelta: { dir: 'down', text: '-0.2pp vs prior period' },
|
||||
p50: '128ms',
|
||||
p50Delta: { dir: 'down', text: '+4ms vs prior period' },
|
||||
p99: '2.3s',
|
||||
p99Delta: { dir: 'down', text: '+200ms vs prior period' }
|
||||
function formatMs(value) {
|
||||
if (!value) {
|
||||
return '0ms';
|
||||
}
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
var COLORS = { REST: 'var(--blue)', GraphQL: 'var(--purple)', gRPC: 'var(--accent)' };
|
||||
var QUOTA = 50000;
|
||||
|
||||
function fmtMs(ms) {
|
||||
return ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : ms + 'ms';
|
||||
}
|
||||
|
||||
function errRateClass(rate) {
|
||||
if (rate > 5) return 'color:var(--red)';
|
||||
if (rate > 1) return 'color:var(--amber)';
|
||||
return 'color:var(--green)';
|
||||
}
|
||||
|
||||
var ARROW_UP = '<svg width="11" height="11"><use href="' + (window.APP_BASE||'') + 'icons/general/arrow-up.svg#icon"/></svg>';
|
||||
var ARROW_DOWN = '<svg width="11" height="11"><use href="' + (window.APP_BASE||'') + 'icons/general/arrow-down.svg#icon"/></svg>';
|
||||
|
||||
// ── Render functions ──────────────────────────────────────────────────────
|
||||
|
||||
function renderChart(period) {
|
||||
var data = CHART_DATA[period];
|
||||
var maxVal = Math.max.apply(null, data.map(function (d) { return d.s + d.e; }));
|
||||
var wrap = document.getElementById('chart-bars');
|
||||
var tmpl = document.getElementById('tmpl-chart-bar');
|
||||
wrap.innerHTML = '';
|
||||
data.forEach(function (d) {
|
||||
var hS = Math.round((d.s / maxVal) * 160);
|
||||
var hE = Math.round((d.e / maxVal) * 160);
|
||||
var node = tmpl.content.cloneNode(true);
|
||||
node.querySelector('.chart-bar.error').style.height = hE + 'px';
|
||||
node.querySelector('.chart-bar.error').dataset.tip = d.e + ' errors';
|
||||
node.querySelector('.chart-bar.success').style.height = hS + 'px';
|
||||
node.querySelector('.chart-bar.success').dataset.tip = d.s.toLocaleString() + ' ok';
|
||||
node.querySelector('.chart-col-label').textContent = d.d;
|
||||
wrap.appendChild(node);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable(period) {
|
||||
var ops = OPS_DATA[period];
|
||||
var tbody = document.getElementById('usage-tbody');
|
||||
var tmpl = document.getElementById('tmpl-usage-row');
|
||||
tbody.innerHTML = '';
|
||||
ops.forEach(function (op) {
|
||||
var errRate = (op.errors / op.calls * 100).toFixed(2);
|
||||
var quotaW = Math.min(op.calls / QUOTA * 100, 100).toFixed(1);
|
||||
var node = tmpl.content.cloneNode(true);
|
||||
|
||||
node.querySelector('.col-name').textContent = op.display;
|
||||
node.querySelector('.col-op-slug').textContent = op.name;
|
||||
node.querySelector('.col-proto').textContent = op.proto;
|
||||
node.querySelector('.col-calls').textContent = op.calls.toLocaleString();
|
||||
node.querySelector('.col-errors').textContent = op.errors.toLocaleString();
|
||||
|
||||
var errEl = node.querySelector('.col-err-rate');
|
||||
errEl.innerHTML = '<span style="' + errRateClass(parseFloat(errRate)) + '">' + errRate + '%</span>';
|
||||
|
||||
var p50w = (op.p50 / op.p99 * 64).toFixed(1);
|
||||
var p95w = ((op.p95 - op.p50) / op.p99 * 64).toFixed(1);
|
||||
var p99w = ((op.p99 - op.p95) / op.p99 * 64).toFixed(1);
|
||||
node.querySelector('.latency-p50').style.width = p50w + 'px';
|
||||
node.querySelector('.latency-p50').title = 'p50: ' + fmtMs(op.p50);
|
||||
node.querySelector('.latency-p95').style.width = p95w + 'px';
|
||||
node.querySelector('.latency-p95').title = 'p95: ' + fmtMs(op.p95);
|
||||
node.querySelector('.latency-p99').style.width = p99w + 'px';
|
||||
node.querySelector('.latency-p99').title = 'p99: ' + fmtMs(op.p99);
|
||||
node.querySelector('.col-latency-text').textContent =
|
||||
fmtMs(op.p50) + ' / ' + fmtMs(op.p95) + ' / ' + fmtMs(op.p99);
|
||||
|
||||
node.querySelector('.col-quota-label').textContent =
|
||||
op.calls.toLocaleString() + ' / ' + QUOTA.toLocaleString();
|
||||
node.querySelector('.col-quota-fill').style.width = quotaW + '%';
|
||||
|
||||
tbody.appendChild(node);
|
||||
});
|
||||
}
|
||||
|
||||
function renderStats(period) {
|
||||
var s = STATS[period];
|
||||
var cards = document.querySelectorAll('.stats-grid .stat-card');
|
||||
var data = [
|
||||
{ value: s.invocations, delta: s.invDelta },
|
||||
{ value: s.success, delta: s.sucDelta },
|
||||
{ value: s.p50, delta: s.p50Delta },
|
||||
{ value: s.p99, delta: s.p99Delta }
|
||||
];
|
||||
for (var i = 0; i < cards.length; i++) {
|
||||
var card = cards[i];
|
||||
var d = data[i];
|
||||
card.querySelector('.stat-value').textContent = d.value;
|
||||
var deltaEl = card.querySelector('.stat-delta');
|
||||
deltaEl.className = 'stat-delta ' + d.delta.dir;
|
||||
var arrow = d.delta.dir === 'up' ? ARROW_UP : ARROW_DOWN;
|
||||
deltaEl.innerHTML = arrow + ' ' + d.delta.text;
|
||||
if (value >= 1000) {
|
||||
return (value / 1000).toFixed(1) + 's';
|
||||
}
|
||||
return value + 'ms';
|
||||
}
|
||||
|
||||
function updateSubtitle(period) {
|
||||
function periodLabel(period) {
|
||||
var labels = {
|
||||
'7d': 'last 7 days',
|
||||
'30d': 'last 30 days',
|
||||
'90d': 'last 90 days',
|
||||
'this_month': 'this month'
|
||||
'7d': 'last 7 days',
|
||||
'30d': 'last 30 days',
|
||||
'90d': 'last 90 days',
|
||||
'this_month': 'this month',
|
||||
};
|
||||
var el = document.querySelector('.section-card-subtitle');
|
||||
if (el) el.textContent = 'Breakdown for ' + (labels[period] || period);
|
||||
return labels[period] || period;
|
||||
}
|
||||
|
||||
// ── CSV export ────────────────────────────────────────────────────────────
|
||||
function protocolColor(protocol) {
|
||||
if (protocol === 'graphql' || protocol === 'Graphql') {
|
||||
return 'var(--purple)';
|
||||
}
|
||||
if (protocol === 'grpc' || protocol === 'Grpc') {
|
||||
return 'var(--accent)';
|
||||
}
|
||||
return 'var(--blue)';
|
||||
}
|
||||
|
||||
function protocolLabel(protocol) {
|
||||
if (protocol === 'graphql' || protocol === 'Graphql') {
|
||||
return 'GraphQL';
|
||||
}
|
||||
if (protocol === 'grpc' || protocol === 'Grpc') {
|
||||
return 'gRPC';
|
||||
}
|
||||
return 'REST';
|
||||
}
|
||||
|
||||
function chartBucketLabel(timestamp, period, index) {
|
||||
var date = new Date(timestamp);
|
||||
if (period === '7d') {
|
||||
return date.toLocaleDateString('en-US', { weekday: 'short' });
|
||||
}
|
||||
if (period === '90d') {
|
||||
return date.toLocaleDateString('en-US', { month: 'short' });
|
||||
}
|
||||
return 'Wk ' + (index + 1);
|
||||
}
|
||||
|
||||
function renderEmpty(message) {
|
||||
chartWrap.innerHTML = '<div style="padding:32px;color:var(--text-muted);text-align:center;width:100%;">' + message + '</div>';
|
||||
tableBody.innerHTML = '<tr><td colspan="7" style="padding:24px;text-align:center;color:var(--text-muted);">' + message + '</td></tr>';
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
var summary = state.usage ? state.usage.summary : null;
|
||||
var cards = [
|
||||
{
|
||||
value: formatCount(summary ? summary.rollup.calls_total : 0),
|
||||
text: 'Across ' + periodLabel(state.period),
|
||||
},
|
||||
{
|
||||
value: ((summary ? summary.success_rate : 0) || 0).toFixed(1) + '%',
|
||||
text: formatCount(summary ? summary.rollup.calls_ok : 0) + ' successful calls',
|
||||
},
|
||||
{
|
||||
value: formatMs(summary ? summary.rollup.p50_ms : 0),
|
||||
text: 'Median latency for selected period',
|
||||
},
|
||||
{
|
||||
value: formatMs(summary ? summary.rollup.p99_ms : 0),
|
||||
text: formatCount(summary ? summary.rollup.calls_error : 0) + ' error calls',
|
||||
}
|
||||
];
|
||||
|
||||
cards.forEach(function (card, index) {
|
||||
var node = statCards[index];
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
node.querySelector('.stat-value').textContent = card.value;
|
||||
var delta = node.querySelector('.stat-delta');
|
||||
delta.className = 'stat-delta';
|
||||
delta.textContent = card.text;
|
||||
});
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
var timeline = state.usage ? state.usage.timeline : [];
|
||||
chartWrap.innerHTML = '';
|
||||
|
||||
if (!timeline.length) {
|
||||
chartWrap.innerHTML = '<div style="padding:32px;color:var(--text-muted);text-align:center;width:100%;">No usage data for selected period</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var maxValue = Math.max.apply(null, timeline.map(function (point) {
|
||||
return point.calls_ok + point.calls_error;
|
||||
}));
|
||||
|
||||
timeline.forEach(function (point, index) {
|
||||
var node = chartTemplate.content.cloneNode(true);
|
||||
var okHeight = maxValue ? Math.round((point.calls_ok / maxValue) * 160) : 0;
|
||||
var errorHeight = maxValue ? Math.round((point.calls_error / maxValue) * 160) : 0;
|
||||
node.querySelector('.chart-bar.success').style.height = okHeight + 'px';
|
||||
node.querySelector('.chart-bar.success').dataset.tip = formatCount(point.calls_ok) + ' ok';
|
||||
node.querySelector('.chart-bar.error').style.height = errorHeight + 'px';
|
||||
node.querySelector('.chart-bar.error').dataset.tip = formatCount(point.calls_error) + ' errors';
|
||||
node.querySelector('.chart-col-label').textContent = chartBucketLabel(point.bucket_start, state.period, index);
|
||||
chartWrap.appendChild(node);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
var operations = state.usage ? state.usage.operations : [];
|
||||
tableBody.innerHTML = '';
|
||||
|
||||
if (!operations.length) {
|
||||
tableBody.innerHTML = '<tr><td colspan="7" style="padding:24px;text-align:center;color:var(--text-muted);">No operation usage data for selected period</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
var totalCalls = operations.reduce(function (sum, operation) {
|
||||
return sum + operation.calls_total;
|
||||
}, 0);
|
||||
|
||||
operations.forEach(function (operation) {
|
||||
var node = rowTemplate.content.cloneNode(true);
|
||||
var errorRate = operation.calls_total === 0
|
||||
? 0
|
||||
: ((operation.calls_error / operation.calls_total) * 100);
|
||||
var share = totalCalls === 0
|
||||
? 0
|
||||
: ((operation.calls_total / totalCalls) * 100);
|
||||
|
||||
node.querySelector('.col-name').textContent = operation.operation_display_name;
|
||||
node.querySelector('.col-op-slug').textContent = operation.operation_name;
|
||||
|
||||
var proto = node.querySelector('.col-proto');
|
||||
proto.textContent = protocolLabel(operation.protocol);
|
||||
proto.style.color = protocolColor(operation.protocol);
|
||||
|
||||
node.querySelector('.col-calls').textContent = formatCount(operation.calls_total);
|
||||
node.querySelector('.col-errors').textContent = formatCount(operation.calls_error);
|
||||
|
||||
var errorRateEl = node.querySelector('.col-err-rate');
|
||||
errorRateEl.textContent = errorRate.toFixed(2) + '%';
|
||||
errorRateEl.style.color = errorRate > 5 ? 'var(--red)' : (errorRate > 1 ? 'var(--amber)' : 'var(--green)');
|
||||
|
||||
var latencyText = node.querySelector('.col-latency-text');
|
||||
latencyText.textContent =
|
||||
formatMs(operation.p50_ms) + ' / ' + formatMs(operation.p95_ms) + ' / ' + formatMs(operation.p99_ms);
|
||||
|
||||
var base = Math.max(operation.p99_ms, 1);
|
||||
node.querySelector('.latency-p50').style.width = Math.max(4, Math.round((operation.p50_ms / base) * 64)) + 'px';
|
||||
node.querySelector('.latency-p95').style.width = Math.max(4, Math.round(((operation.p95_ms - operation.p50_ms) / base) * 64)) + 'px';
|
||||
node.querySelector('.latency-p99').style.width = Math.max(4, Math.round(((operation.p99_ms - operation.p95_ms) / base) * 64)) + 'px';
|
||||
|
||||
node.querySelector('.col-quota-label').textContent = share.toFixed(1) + '% of workspace traffic';
|
||||
node.querySelector('.col-quota-fill').style.width = share.toFixed(1) + '%';
|
||||
|
||||
tableBody.appendChild(node);
|
||||
});
|
||||
}
|
||||
|
||||
function renderUsage() {
|
||||
if (state.loading && !state.usage) {
|
||||
renderEmpty('Loading usage...');
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.loadError) {
|
||||
renderEmpty(state.loadError);
|
||||
return;
|
||||
}
|
||||
|
||||
renderStats();
|
||||
renderChart();
|
||||
renderTable();
|
||||
if (subtitle) {
|
||||
subtitle.textContent = 'Breakdown for ' + periodLabel(state.period);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsage() {
|
||||
if (!window.CrankApi) {
|
||||
state.loadError = 'Admin API is not available';
|
||||
renderUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
state.workspaceId = currentWorkspaceId();
|
||||
if (!state.workspaceId) {
|
||||
state.loadError = 'Workspace is not selected';
|
||||
renderUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
state.loading = true;
|
||||
state.loadError = '';
|
||||
renderUsage();
|
||||
|
||||
try {
|
||||
state.usage = await window.CrankApi.getUsageOverview(state.workspaceId, { period: state.period });
|
||||
} catch (error) {
|
||||
state.loadError = error.message || 'Failed to load usage';
|
||||
} finally {
|
||||
state.loading = false;
|
||||
renderUsage();
|
||||
}
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
if (!state.usage) {
|
||||
return;
|
||||
}
|
||||
|
||||
function exportCSV(period) {
|
||||
var ops = OPS_DATA[period];
|
||||
var rows = ['Operation,Protocol,Calls,Errors,Error Rate (%),p50 (ms),p95 (ms),p99 (ms)'];
|
||||
ops.forEach(function (op) {
|
||||
var errRate = (op.errors / op.calls * 100).toFixed(2);
|
||||
state.usage.operations.forEach(function (operation) {
|
||||
var errorRate = operation.calls_total === 0
|
||||
? 0
|
||||
: ((operation.calls_error / operation.calls_total) * 100);
|
||||
rows.push([
|
||||
'"' + op.display + '"',
|
||||
op.proto,
|
||||
op.calls,
|
||||
op.errors,
|
||||
errRate,
|
||||
op.p50,
|
||||
op.p95,
|
||||
op.p99
|
||||
'"' + operation.operation_display_name + '"',
|
||||
'"' + protocolLabel(operation.protocol) + '"',
|
||||
operation.calls_total,
|
||||
operation.calls_error,
|
||||
errorRate.toFixed(2),
|
||||
operation.p50_ms,
|
||||
operation.p95_ms,
|
||||
operation.p99_ms,
|
||||
].join(','));
|
||||
});
|
||||
var csv = rows.join('\r\n');
|
||||
var blob = new Blob([csv], {type: 'text/csv'});
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'crank-usage-' + period + '.csv';
|
||||
a.click();
|
||||
|
||||
var blob = new Blob([rows.join('\r\n')], { type: 'text/csv' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'crank-usage-' + state.period + '.csv';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ── Period selector ───────────────────────────────────────────────────────
|
||||
|
||||
var currentPeriod = '7d';
|
||||
|
||||
var periodSelect = document.getElementById('period');
|
||||
if (periodSelect) {
|
||||
periodSelect.value = currentPeriod;
|
||||
periodSelect.value = state.period;
|
||||
periodSelect.addEventListener('change', function () {
|
||||
currentPeriod = this.value;
|
||||
renderChart(currentPeriod);
|
||||
renderTable(currentPeriod);
|
||||
renderStats(currentPeriod);
|
||||
updateSubtitle(currentPeriod);
|
||||
state.period = this.value;
|
||||
loadUsage();
|
||||
});
|
||||
}
|
||||
|
||||
// Export CSV button — first .btn-secondary inside .page-header-actions
|
||||
var exportBtn = document.querySelector('.page-header-actions .btn-secondary');
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener('click', function () {
|
||||
exportCSV(currentPeriod);
|
||||
});
|
||||
exportBtn.addEventListener('click', exportCsv);
|
||||
}
|
||||
|
||||
// ── Initial render ────────────────────────────────────────────────────────
|
||||
|
||||
renderChart(currentPeriod);
|
||||
renderTable(currentPeriod);
|
||||
renderStats(currentPeriod);
|
||||
updateSubtitle(currentPeriod);
|
||||
window.addEventListener('crank:workspacechange', loadUsage);
|
||||
|
||||
if (window.whenWorkspacesReady) {
|
||||
window.whenWorkspacesReady().finally(loadUsage);
|
||||
} else {
|
||||
loadUsage();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user