293 lines
9.5 KiB
JavaScript
293 lines
9.5 KiB
JavaScript
document.addEventListener('DOMContentLoaded', function () {
|
|
var state = {
|
|
period: '7d',
|
|
workspaceId: null,
|
|
usage: null,
|
|
loading: false,
|
|
loadError: '',
|
|
};
|
|
|
|
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 currentWorkspaceId() {
|
|
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
|
return workspace ? workspace.id : null;
|
|
}
|
|
|
|
function formatCount(value) {
|
|
return Number(value || 0).toLocaleString();
|
|
}
|
|
|
|
function formatMs(value) {
|
|
if (!value) {
|
|
return '0ms';
|
|
}
|
|
if (value >= 1000) {
|
|
return (value / 1000).toFixed(1) + 's';
|
|
}
|
|
return value + 'ms';
|
|
}
|
|
|
|
function periodLabel(period) {
|
|
var labels = {
|
|
'7d': 'last 7 days',
|
|
'30d': 'last 30 days',
|
|
'90d': 'last 90 days',
|
|
'this_month': 'this month',
|
|
};
|
|
return labels[period] || period;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
var rows = ['Operation,Protocol,Calls,Errors,Error Rate (%),p50 (ms),p95 (ms),p99 (ms)'];
|
|
state.usage.operations.forEach(function (operation) {
|
|
var errorRate = operation.calls_total === 0
|
|
? 0
|
|
: ((operation.calls_error / operation.calls_total) * 100);
|
|
rows.push([
|
|
'"' + 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 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);
|
|
}
|
|
|
|
if (periodSelect) {
|
|
periodSelect.value = state.period;
|
|
periodSelect.addEventListener('change', function () {
|
|
state.period = this.value;
|
|
loadUsage();
|
|
});
|
|
}
|
|
|
|
if (exportBtn) {
|
|
exportBtn.addEventListener('click', exportCsv);
|
|
}
|
|
|
|
window.addEventListener('crank:workspacechange', loadUsage);
|
|
|
|
if (window.whenWorkspacesReady) {
|
|
window.whenWorkspacesReady().finally(loadUsage);
|
|
} else {
|
|
loadUsage();
|
|
}
|
|
});
|