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(); } });