550 lines
18 KiB
JavaScript
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();
|
|
}
|
|
});
|