Files
crank/apps/ui/js/usage.js
T
github-ops ba29ac7b94
Deploy / deploy (push) Successful in 2m44s
CI / Rust Checks (push) Successful in 5m31s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 2s
CI / Frontend E2E (push) Successful in 4m24s
chore: publish clean community baseline
2026-06-19 16:45:51 +00:00

550 lines
18 KiB
JavaScript

document.addEventListener('DOMContentLoaded', function () {
var state = {
period: '7d',
workspaceId: null,
usage: null,
derived: {
localizedOperations: [],
normalizedTimeline: [],
timelineMaxValue: 0,
totalCalls: 0,
},
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 cardList = document.getElementById('usage-card-list');
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 tKey(key) {
return window.t ? t(key) : key;
}
function tfKey(key, vars) {
return window.tf ? tf(key, vars) : tKey(key);
}
function currentLocale() {
return localStorage.getItem('crank_lang') === 'ru' ? 'ru-RU' : 'en-US';
}
function currentWorkspaceId() {
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
return workspace ? workspace.id : null;
}
function element(tag, className, text) {
var node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined && text !== null) node.textContent = text;
return node;
}
function formatCount(value) {
return Number(value || 0).toLocaleString(currentLocale());
}
function formatMs(value) {
if (!value) {
return '0ms';
}
if (value >= 1000) {
return (value / 1000).toFixed(1) + 's';
}
return value + 'ms';
}
function periodLabel(period) {
return tKey('usage.period.label.' + period);
}
function protocolColor(protocol) {
return 'var(--blue)';
}
function protocolLabel(protocol) {
return 'REST';
}
function localizedUsageOperation(operation) {
if (!window.localizeDemoOperation) {
return operation;
}
return window.localizeDemoOperation({
name: operation.operation_name,
operation_display_name: operation.operation_display_name,
description: operation.operation_description || null,
});
}
function chartBucketLabel(timestamp, period, index) {
var date = new Date(timestamp);
if (period === '7d') {
return date.toLocaleDateString(currentLocale(), { weekday: 'short' });
}
if (period === '90d') {
return date.toLocaleDateString(currentLocale(), { month: 'short' });
}
return tfKey('usage.chart.week', { index: index + 1 });
}
function toUtcBucketStart(date, period) {
var bucket = new Date(date.getTime());
bucket.setUTCMilliseconds(0);
bucket.setUTCSeconds(0);
bucket.setUTCMinutes(0);
bucket.setUTCHours(0);
if (period === '30d' || period === 'this_month') {
var day = bucket.getUTCDay();
var offset = day === 0 ? 6 : (day - 1);
bucket.setUTCDate(bucket.getUTCDate() - offset);
return bucket;
}
if (period === '90d') {
bucket.setUTCDate(1);
return bucket;
}
return bucket;
}
function shiftUtcBucket(date, period, amount) {
var shifted = new Date(date.getTime());
if (period === '30d' || period === 'this_month') {
shifted.setUTCDate(shifted.getUTCDate() + (amount * 7));
return shifted;
}
if (period === '90d') {
shifted.setUTCMonth(shifted.getUTCMonth() + amount);
return shifted;
}
shifted.setUTCDate(shifted.getUTCDate() + amount);
return shifted;
}
function bucketKey(date) {
return date.toISOString().slice(0, 19) + 'Z';
}
function expectedBucketCount(period, endBucket) {
if (period === '7d') {
return 7;
}
if (period === '30d') {
return 5;
}
if (period === '90d') {
return 4;
}
if (period === 'this_month') {
var firstDay = new Date(Date.UTC(endBucket.getUTCFullYear(), endBucket.getUTCMonth(), 1));
var firstWeek = toUtcBucketStart(firstDay, period);
var diffMs = endBucket.getTime() - firstWeek.getTime();
return Math.max(1, Math.floor(diffMs / (7 * 24 * 60 * 60 * 1000)) + 1);
}
return 7;
}
function normalizeTimeline(timeline, period) {
if (!timeline.length) {
return [];
}
var byBucket = {};
timeline.forEach(function(point) {
byBucket[point.bucket_start] = point;
});
var latestBucket = timeline.reduce(function(current, point) {
return point.bucket_start > current.bucket_start ? point : current;
}, timeline[0]);
var endBucket = toUtcBucketStart(new Date(latestBucket.bucket_start), period);
var bucketCount = expectedBucketCount(period, endBucket);
var startBucket = shiftUtcBucket(endBucket, period, -(bucketCount - 1));
var normalized = [];
for (var index = 0; index < bucketCount; index += 1) {
var bucketDate = shiftUtcBucket(startBucket, period, index);
var key = bucketKey(bucketDate);
normalized.push(byBucket[key] || {
bucket_start: key,
calls_ok: 0,
calls_error: 0,
});
}
return normalized;
}
function recomputeDerivedState() {
var operations = state.usage ? (state.usage.operations || []) : [];
var timeline = state.usage ? normalizeTimeline(state.usage.timeline || [], state.period) : [];
var totalCalls = operations.reduce(function (sum, operation) {
return sum + operation.calls_total;
}, 0);
var timelineMaxValue = timeline.reduce(function (maxValue, point) {
return Math.max(maxValue, point.calls_ok + point.calls_error);
}, 0);
state.derived = {
localizedOperations: operations.map(function (operation) {
return {
operation: operation,
localized: localizedUsageOperation(operation),
};
}),
normalizedTimeline: timeline,
timelineMaxValue: timelineMaxValue,
totalCalls: totalCalls,
};
}
function buildEmptyState(title, message, compact) {
var empty = element('div', 'empty-state');
if (compact) {
empty.style.padding = '0';
}
empty.appendChild(element('div', 'empty-state-title', title));
empty.appendChild(element('div', 'empty-state-text', message));
return empty;
}
function renderEmpty(title, message) {
chartWrap.innerHTML = '';
var chartEmpty = buildEmptyState(title, message, false);
chartEmpty.style.width = '100%';
chartWrap.appendChild(chartEmpty);
tableBody.innerHTML = '';
var row = document.createElement('tr');
var cell = document.createElement('td');
cell.colSpan = 7;
cell.style.padding = '24px';
cell.appendChild(buildEmptyState(title, message, true));
row.appendChild(cell);
tableBody.appendChild(row);
}
function renderStats() {
var summary = state.usage ? state.usage.summary : null;
var cards = [
{
value: formatCount(summary ? summary.rollup.calls_total : 0),
text: tfKey('usage.stats.across', { period: periodLabel(state.period) }),
},
{
value: ((summary ? summary.success_rate : 0) || 0).toFixed(1) + '%',
text: tfKey('usage.stats.success_calls', { count: formatCount(summary ? summary.rollup.calls_ok : 0) }),
},
{
value: formatMs(summary ? summary.rollup.p50_ms : 0),
text: tKey('usage.stats.median'),
},
{
value: formatMs(summary ? summary.rollup.p99_ms : 0),
text: tfKey('usage.stats.error_calls', { count: formatCount(summary ? summary.rollup.calls_error : 0) }),
}
];
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.derived.normalizedTimeline;
chartWrap.innerHTML = '';
if (!timeline.length) {
var chartEmpty = buildEmptyState(
tKey('usage.chart.empty.title'),
tKey('usage.chart.empty.sub'),
false
);
chartEmpty.style.width = '100%';
chartWrap.appendChild(chartEmpty);
return;
}
var maxValue = state.derived.timelineMaxValue;
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;
if (point.calls_ok > 0) {
okHeight = Math.max(okHeight, 6);
}
if (point.calls_error > 0) {
errorHeight = Math.max(errorHeight, 6);
}
node.querySelector('.chart-bar.success').style.height = okHeight + 'px';
node.querySelector('.chart-bar.success').dataset.tip = tfKey('usage.chart.ok', { count: formatCount(point.calls_ok) });
node.querySelector('.chart-bar.error').style.height = errorHeight + 'px';
node.querySelector('.chart-bar.error').dataset.tip = tfKey('usage.chart.errors', { count: formatCount(point.calls_error) });
node.querySelector('.chart-col-label').textContent = chartBucketLabel(point.bucket_start, state.period, index);
chartWrap.appendChild(node);
});
}
function renderTable() {
var operations = state.derived.localizedOperations;
tableBody.innerHTML = '';
if (cardList) {
cardList.innerHTML = '';
}
if (!operations.length) {
var row = document.createElement('tr');
var cell = document.createElement('td');
cell.colSpan = 7;
cell.style.padding = '24px';
cell.appendChild(
buildEmptyState(
tKey('usage.table.empty.title'),
tKey('usage.table.empty.sub'),
true
)
);
row.appendChild(cell);
tableBody.appendChild(row);
renderOperationCards([]);
return;
}
operations.forEach(function (entry) {
var operation = entry.operation;
var localizedOperation = entry.localized;
var node = rowTemplate.content.cloneNode(true);
var errorRate = operation.calls_total === 0
? 0
: ((operation.calls_error / operation.calls_total) * 100);
var share = state.derived.totalCalls === 0
? 0
: ((operation.calls_total / state.derived.totalCalls) * 100);
node.querySelector('.col-name').textContent = localizedOperation.operation_display_name || 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 = tfKey('usage.table.share', { value: share.toFixed(1) });
node.querySelector('.col-quota-fill').style.width = share.toFixed(1) + '%';
tableBody.appendChild(node);
});
renderOperationCards(operations);
}
function renderOperationCards(operations) {
if (!cardList) {
return;
}
cardList.innerHTML = '';
if (!operations.length) {
cardList.appendChild(buildEmptyState(
tKey('usage.table.empty.title'),
tKey('usage.table.empty.sub'),
false
));
return;
}
operations.forEach(function(entry) {
var operation = entry.operation;
var localizedOperation = entry.localized;
var errorRate = operation.calls_total === 0
? 0
: ((operation.calls_error / operation.calls_total) * 100);
var share = state.derived.totalCalls === 0
? 0
: ((operation.calls_total / state.derived.totalCalls) * 100);
var card = element('div', 'resource-card');
var header = element('div', 'resource-card-header');
var headerMain = element('div');
headerMain.appendChild(element(
'div',
'resource-card-title',
localizedOperation.operation_display_name || operation.operation_display_name
));
headerMain.appendChild(element('div', 'resource-card-subtitle', operation.operation_name));
var actions = element('div', 'resource-card-actions');
var protocolBadge = element('span', 'badge');
protocolBadge.textContent = protocolLabel(operation.protocol);
protocolBadge.style.color = protocolColor(operation.protocol);
actions.appendChild(protocolBadge);
header.appendChild(headerMain);
header.appendChild(actions);
card.appendChild(header);
var metaGrid = element('div', 'resource-meta-grid');
metaGrid.appendChild(buildUsageMetaItem('usage.table.th.calls', formatCount(operation.calls_total)));
metaGrid.appendChild(buildUsageMetaItem('usage.table.th.errors', formatCount(operation.calls_error)));
metaGrid.appendChild(buildUsageMetaItem('usage.table.th.error_rate', errorRate.toFixed(2) + '%'));
metaGrid.appendChild(buildUsageMetaItem(
'usage.table.th.latency',
formatMs(operation.p50_ms) + ' / ' + formatMs(operation.p95_ms) + ' / ' + formatMs(operation.p99_ms)
));
metaGrid.appendChild(buildUsageMetaItem('usage.table.th.share', tfKey('usage.table.share', { value: share.toFixed(1) })));
card.appendChild(metaGrid);
cardList.appendChild(card);
});
}
function buildUsageMetaItem(labelKey, valueText) {
var item = element('div', 'resource-meta-item');
item.appendChild(element('div', 'resource-meta-label', tKey(labelKey)));
item.appendChild(element('div', 'resource-meta-value', valueText));
return item;
}
function renderUsage() {
if (state.loading && !state.usage) {
renderEmpty(tKey('usage.loading.title'), tKey('usage.loading.sub'));
return;
}
if (state.loadError) {
renderEmpty(tKey('usage.error.title'), state.loadError);
return;
}
renderStats();
renderChart();
renderTable();
if (subtitle) {
subtitle.textContent = tfKey('usage.table.subtitle', { period: periodLabel(state.period) });
}
}
async function loadUsage() {
if (!window.CrankApi) {
state.loadError = tKey('usage.error.api');
renderUsage();
return;
}
state.workspaceId = currentWorkspaceId();
if (!state.workspaceId) {
state.loadError = tKey('usage.error.workspace');
renderUsage();
return;
}
state.loading = true;
state.loadError = '';
renderUsage();
try {
state.usage = await window.CrankApi.getUsageOverview(state.workspaceId, { period: state.period });
recomputeDerivedState();
} catch (error) {
state.loadError = error.message || tKey('usage.error.load');
state.usage = null;
recomputeDerivedState();
} finally {
state.loading = false;
renderUsage();
}
}
function exportCsv() {
if (!state.usage) {
if (window.CrankUi) {
window.CrankUi.info(tKey('usage.export.empty.body'), tKey('usage.export.empty.title'));
}
return;
}
var rows = [tKey('usage.csv.header')];
state.usage.operations.forEach(function (operation) {
var localizedOperation = localizedUsageOperation(operation);
var errorRate = operation.calls_total === 0
? 0
: ((operation.calls_error / operation.calls_total) * 100);
rows.push([
'"' + (localizedOperation.operation_display_name || 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 (window.CrankUi) {
window.CrankUi.success(tKey('usage.export.done.body'), tKey('usage.export.done.title'));
}
}
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();
}
});