diff --git a/TASKS.md b/TASKS.md
index fb7b1fa..bb87980 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -2,19 +2,20 @@
## Current
-### `feat/alpine-api-keys`
+### `feat/alpine-logs-usage`
Status: completed
DoD:
-- `API Keys` page читает live keys catalog из `admin-api`
-- `API Keys` page использует live `create/revoke/delete` flow вместо `keys.json`
-- scope model и тексты в UI выровнены с реальным backend
+- `Logs` page читает live invocation logs из `admin-api`
+- `Logs` page использует workspace-scoped filters и polling вместо seed data
+- `Usage` page читает live usage overview из `admin-api`
+- `Usage` page строит chart/table/stat cards из реальных usage aggregates
- `test-ui` остается нетронутым как fallback
## Next
-- `feat/alpine-logs-usage`
+- `feat/alpine-settings-workspace`
## Backlog
diff --git a/apps/ui/html/logs.html b/apps/ui/html/logs.html
index efbdc0c..4752db5 100644
--- a/apps/ui/html/logs.html
+++ b/apps/ui/html/logs.html
@@ -13,6 +13,7 @@
+
diff --git a/apps/ui/html/usage.html b/apps/ui/html/usage.html
index 88380bb..0970603 100644
--- a/apps/ui/html/usage.html
+++ b/apps/ui/html/usage.html
@@ -13,6 +13,7 @@
+
@@ -176,7 +177,7 @@
diff --git a/apps/ui/js/api.js b/apps/ui/js/api.js
index 221760e..e5f3d58 100644
--- a/apps/ui/js/api.js
+++ b/apps/ui/js/api.js
@@ -67,6 +67,19 @@
return request(path, { method: 'DELETE' });
}
+ function query(params) {
+ var search = new URLSearchParams();
+ Object.keys(params || {}).forEach(function(key) {
+ var value = params[key];
+ if (value === undefined || value === null || value === '') {
+ return;
+ }
+ search.set(key, value);
+ });
+ var encoded = search.toString();
+ return encoded ? ('?' + encoded) : '';
+ }
+
function postBytes(path, bytes, fileName) {
return request(path, {
method: 'POST',
@@ -143,5 +156,24 @@
deletePlatformApiKey: function(workspaceId, keyId) {
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys/' + encodeURIComponent(keyId));
},
+ listLogs: function(workspaceId, params) {
+ return get('/workspaces/' + encodeURIComponent(workspaceId) + '/logs' + query(params));
+ },
+ getLog: function(workspaceId, logId) {
+ return get('/workspaces/' + encodeURIComponent(workspaceId) + '/logs/' + encodeURIComponent(logId));
+ },
+ getUsageOverview: function(workspaceId, params) {
+ return get('/workspaces/' + encodeURIComponent(workspaceId) + '/usage' + query(params));
+ },
+ getOperationUsage: function(workspaceId, operationId, params) {
+ return get(
+ '/workspaces/' + encodeURIComponent(workspaceId) + '/usage/operations/' + encodeURIComponent(operationId) + query(params)
+ );
+ },
+ getAgentUsage: function(workspaceId, agentId, params) {
+ return get(
+ '/workspaces/' + encodeURIComponent(workspaceId) + '/usage/agents/' + encodeURIComponent(agentId) + query(params)
+ );
+ },
};
}());
diff --git a/apps/ui/js/logs.js b/apps/ui/js/logs.js
index 2ef3086..9d0bbe0 100644
--- a/apps/ui/js/logs.js
+++ b/apps/ui/js/logs.js
@@ -1,254 +1,130 @@
-// logs.js — Crank log viewer
-
document.addEventListener('DOMContentLoaded', function () {
+ var state = {
+ logs: [],
+ details: {},
+ level: 'all',
+ search: '',
+ period: '7d',
+ openId: null,
+ liveMode: true,
+ timer: null,
+ workspaceId: null,
+ loading: false,
+ loadError: '',
+ };
- // ── Helpers ────────────────────────────────────────────────────────────────
-
- function minsAgo(mins) {
- return new Date(Date.now() - mins * 60 * 1000);
- }
-
- function fmtTs(date) {
- var d = (date instanceof Date) ? date : new Date(date);
- var pad = function (n, w) { return String(n).padStart(w || 2, '0'); };
- return (
- d.getFullYear() + '-' +
- pad(d.getMonth() + 1) + '-' +
- pad(d.getDate()) + ' ' +
- pad(d.getHours()) + ':' +
- pad(d.getMinutes()) + ':' +
- pad(d.getSeconds()) + '.' +
- pad(d.getMilliseconds(), 3)
- );
- }
-
- // ── Seed log data ──────────────────────────────────────────────────────────
- // Timestamps are computed relative to Date.now() so time-range filtering works.
-
- var LOGS = [
- {
- id: 1, ts: minsAgo(2), level: 'info', op: 'create_crm_lead',
- msg: 'Tool invoked — POST /v1/leads', duration: '142ms', status: 201,
- detail: {
- req: '{"first_name":"Alice","last_name":"Smith","email":"alice@acme.com"}',
- res: '{"lead_id":"ld_9f3a","status":"new","created_at":"2026-03-27T14:32:11Z"}'
- }
- },
- {
- id: 2, ts: minsAgo(5), level: 'info', op: 'get_user_profile',
- msg: 'Tool invoked — GET /v1/users/me', duration: '38ms', status: 200,
- detail: {
- req: '{"user_id":"usr_0042"}',
- res: '{"id":"usr_0042","name":"Alice Smith","email":"alice@acme.com","role":"admin"}'
- }
- },
- {
- id: 3, ts: minsAgo(12), level: 'warn', op: 'send_slack_message',
- msg: 'Rate limited by Slack API — retrying after back-off', duration: '1842ms', status: 429,
- detail: {
- req: '{"channel":"#ops","text":"Deploy finished"}',
- res: '{"error":"ratelimited","retry_after":30}'
- }
- },
- {
- id: 4, ts: minsAgo(18), level: 'info', op: 'fetch_invoice',
- msg: 'Tool invoked — GET /invoices/inv_003', duration: '95ms', status: 200,
- detail: {
- req: '{"invoice_id":"inv_003"}',
- res: '{"id":"inv_003","amount":2400,"currency":"USD","status":"paid"}'
- }
- },
- {
- id: 5, ts: minsAgo(26), level: 'error', op: 'stream_telemetry',
- msg: 'Upstream unavailable', duration: '0ms', status: 503,
- detail: {
- req: '{"stream_id":"st_881","since":"2026-03-27T14:00:00Z"}',
- res: '503 Service Unavailable\nRetry-After: 60'
- }
- },
- {
- id: 6, ts: minsAgo(38), level: 'info', op: 'create_crm_lead',
- msg: 'Tool invoked — POST /v1/leads', duration: '167ms', status: 201,
- detail: {
- req: '{"first_name":"Bob","last_name":"Jones","email":"bob@example.com"}',
- res: '{"lead_id":"ld_9f3b","status":"new"}'
- }
- },
- {
- id: 7, ts: minsAgo(52), level: 'debug', op: 'get_user_profile',
- msg: 'Schema validation passed — 4 fields mapped', duration: '41ms', status: 200,
- detail: {
- req: '{"user_id":"usr_0041"}',
- res: '{"id":"usr_0041","name":"Bob Jones","email":"bob@example.com","role":"viewer"}'
- }
- },
- {
- id: 8, ts: minsAgo(75), level: 'info', op: 'list_products',
- msg: 'Tool invoked — GET /api/products', duration: '210ms', status: 200,
- detail: {
- req: '{"limit":20,"page":1}',
- res: '{"total":142,"items":[{"id":"p01","name":"Mechanical Keyboard","price":149.99},...]}'
- }
- },
- {
- id: 9, ts: minsAgo(110), level: 'info', op: 'create_support_ticket',
- msg: 'Tool invoked — POST /v1/tickets', duration: '178ms', status: 201,
- detail: {
- req: '{"subject":"Login issue","priority":"high","user_id":"usr_0042"}',
- res: '{"ticket_id":"tkt_5521","status":"open","created_at":"2026-03-27T12:42:11Z"}'
- }
- },
- {
- id: 10, ts: minsAgo(190), level: 'error', op: 'send_slack_message',
- msg: 'Upstream returned 503 Service Unavailable — retried 3×', duration: '0ms', status: 503,
- detail: {
- req: '{"channel":"#alerts","text":"DB failover triggered"}',
- res: '503 Service Unavailable\nRetry-After: 30'
- }
- },
- {
- id: 11, ts: minsAgo(280), level: 'info', op: 'fetch_invoice',
- msg: 'Tool invoked — GET /invoices/inv_002', duration: '88ms', status: 200,
- detail: {
- req: '{"invoice_id":"inv_002"}',
- res: '{"id":"inv_002","amount":1250,"currency":"USD","status":"open"}'
- }
- },
- {
- id: 12, ts: minsAgo(420), level: 'warn', op: 'get_order_status',
- msg: 'Validation error: missing field order_id', duration: '55ms', status: 422,
- detail: {
- req: '{"customer_id":"cus_009"}',
- res: '{"error":"validation_failed","fields":["order_id"]}'
- }
- },
- {
- id: 13, ts: minsAgo(720), level: 'info', op: 'create_crm_lead',
- msg: 'Tool invoked — POST /v1/leads', duration: '155ms', status: 200,
- detail: {
- req: '{"first_name":"Carol","last_name":"Davis","email":"carol@co.io"}',
- res: '{"lead_id":"ld_9f3c","status":"new"}'
- }
- },
- {
- id: 14, ts: minsAgo(2200), level: 'info', op: 'sync_contacts',
- msg: 'Tool invoked — POST /v1/contacts/sync', duration: '340ms', status: 200,
- detail: {
- req: '{"source":"hubspot","since":"2026-03-25T00:00:00Z"}',
- res: '{"synced":48,"skipped":2,"errors":0}'
- }
- },
- {
- id: 15, ts: minsAgo(7200), level: 'debug', op: 'list_products',
- msg: 'Cache miss — fetching from origin', duration: '198ms', status: 200,
- detail: {
- req: '{"limit":10,"page":1}',
- res: '{"total":142,"items":[...]}'
- }
- }
- ];
-
- // ── State ──────────────────────────────────────────────────────────────────
-
- var levelFilter = 'all';
- var searchText = '';
- var timeRange = '7d';
- var openId = null;
- var liveMode = true;
- var liveTimer = null;
- var nextId = 100;
-
- // ── DOM refs ───────────────────────────────────────────────────────────────
-
- var logList = document.getElementById('log-list');
- var logSearch = document.getElementById('log-search');
- var refreshBtn = document.getElementById('refresh-btn');
+ var logList = document.getElementById('log-list');
+ var logSearch = document.getElementById('log-search');
+ var refreshBtn = document.getElementById('refresh-btn');
var timeRangeSel = document.getElementById('time-range');
- var liveDot = document.querySelector('.live-dot');
- var liveLabel = document.querySelector('.live-label');
+ var liveDot = document.querySelector('.live-dot');
+ var liveLabel = document.querySelector('.live-label');
- // ── Debug chip injection ───────────────────────────────────────────────────
- // The HTML has All/Info/Warn/Error chips. We add Debug after Error.
-
- var chipError = document.getElementById('chip-error');
- if (chipError && !document.getElementById('chip-debug')) {
- var debugChip = document.createElement('button');
- debugChip.className = 'filter-chip';
- debugChip.setAttribute('data-level', 'debug');
- debugChip.id = 'chip-debug';
- var debugSpan = document.createElement('span');
- debugSpan.className = 'log-level debug';
- debugSpan.style.cssText = 'padding:0 4px;font-size:10px;';
- debugSpan.textContent = 'DEBUG';
- debugChip.appendChild(debugSpan);
- chipError.parentNode.insertBefore(debugChip, chipError.nextSibling);
+ function currentWorkspaceId() {
+ var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
+ return workspace ? workspace.id : null;
}
- // ── Time-range helpers ─────────────────────────────────────────────────────
-
- function rangeMs(range) {
- var map = { '30m': 30, '1h': 60, '6h': 360, '24h': 1440, '7d': 10080 };
- var mins = map[range] || 10080;
- return mins * 60 * 1000;
+ function durationLabel(durationMs) {
+ if (!durationMs) {
+ return '0ms';
+ }
+ if (durationMs >= 1000) {
+ return (durationMs / 1000).toFixed(1) + 's';
+ }
+ return durationMs + 'ms';
}
- // ── Render ─────────────────────────────────────────────────────────────────
+ function formatJson(value) {
+ if (value === null || value === undefined) {
+ return '';
+ }
+ if (typeof value === 'string') {
+ return value;
+ }
+ return JSON.stringify(value, null, 2);
+ }
- function statusClass(s) {
- if (!s) return '';
- if (s < 300) return 'ok';
- if (s < 500) return 'warn';
+ function formatTime(timestamp) {
+ var date = new Date(timestamp);
+ return date.toISOString().slice(11, 23);
+ }
+
+ function statusClass(statusCode) {
+ if (!statusCode) {
+ return '';
+ }
+ if (statusCode < 300) {
+ return 'ok';
+ }
+ if (statusCode < 500) {
+ return 'warn';
+ }
return 'err';
}
- function escText(str) {
- // Safely return string; all user-facing dynamic strings go through textContent,
- // but for pre blocks in detail we use textContent assignment too (see below).
- return str;
+ function normalizeLog(record) {
+ var log = record.log;
+ return {
+ id: log.id,
+ createdAt: log.created_at,
+ level: log.level,
+ source: log.source,
+ status: log.status,
+ statusCode: log.status_code,
+ durationMs: log.duration_ms,
+ toolName: log.tool_name,
+ message: log.message,
+ operationName: record.operation_name,
+ operationDisplayName: record.operation_display_name,
+ agentSlug: record.agent_slug,
+ agentDisplayName: record.agent_display_name,
+ requestPreview: log.request_preview,
+ responsePreview: log.response_preview,
+ errorKind: log.error_kind,
+ requestId: log.request_id,
+ };
+ }
+
+ function renderEmpty(message) {
+ logList.innerHTML = '';
+ var empty = document.createElement('div');
+ empty.style.cssText = 'text-align:center;padding:40px;color:var(--text-muted);font-size:13.5px;';
+ empty.textContent = message;
+ logList.appendChild(empty);
}
function renderLogs() {
- var q = searchText.toLowerCase();
- var cutoff = Date.now() - rangeMs(timeRange);
-
- var rows = LOGS.filter(function (l) {
- var ts = (l.ts instanceof Date) ? l.ts.getTime() : new Date(l.ts).getTime();
- var matchTime = ts >= cutoff;
- var matchLevel = levelFilter === 'all' || l.level === levelFilter;
- var matchSearch = !q ||
- l.msg.toLowerCase().indexOf(q) !== -1 ||
- l.op.indexOf(q) !== -1;
- return matchTime && matchLevel && matchSearch;
- });
-
- // Build DOM nodes instead of innerHTML for XSS safety where data is dynamic.
- // For static/controlled detail payloads we still use textContent on pre elements.
- var frag = document.createDocumentFragment();
-
- if (!rows.length) {
- var empty = document.createElement('div');
- empty.style.cssText = 'text-align:center;padding:40px;color:var(--text-muted);font-size:13.5px;';
- empty.textContent = 'No log entries match the current filter';
- frag.appendChild(empty);
+ if (state.loading && state.logs.length === 0) {
+ renderEmpty('Loading logs...');
+ return;
}
- rows.forEach(function (l) {
- var tsDate = (l.ts instanceof Date) ? l.ts : new Date(l.ts);
- var timeStr = fmtTs(tsDate).slice(11, 23); // HH:MM:SS.mmm
+ if (state.loadError) {
+ renderEmpty(state.loadError);
+ return;
+ }
- // ── Row ──
+ if (!state.logs.length) {
+ renderEmpty('No log entries match the current filter');
+ return;
+ }
+
+ var fragment = document.createDocumentFragment();
+
+ state.logs.forEach(function (item) {
var row = document.createElement('div');
row.className = 'log-entry';
- row.setAttribute('data-id', l.id);
+ row.setAttribute('data-id', item.id);
var timeEl = document.createElement('span');
timeEl.className = 'log-time';
- timeEl.textContent = timeStr;
+ timeEl.textContent = formatTime(item.createdAt);
row.appendChild(timeEl);
var levelEl = document.createElement('span');
- levelEl.className = 'log-level ' + l.level;
- levelEl.textContent = l.level.toUpperCase();
+ levelEl.className = 'log-level ' + item.level;
+ levelEl.textContent = item.level.toUpperCase();
row.appendChild(levelEl);
var msgDiv = document.createElement('div');
@@ -256,232 +132,249 @@ document.addEventListener('DOMContentLoaded', function () {
var opSpan = document.createElement('span');
opSpan.className = 'log-op-name';
- opSpan.textContent = l.op;
+ opSpan.textContent = item.operationName;
msgDiv.appendChild(opSpan);
+ msgDiv.appendChild(document.createTextNode('\u00a0\u00a0'));
- msgDiv.appendChild(document.createTextNode('\u00a0\u00a0')); // two nbsps
-
- if (l.status) {
+ if (item.statusCode) {
var statusEl = document.createElement('span');
- statusEl.className = 'log-status ' + statusClass(l.status);
- statusEl.textContent = l.status;
+ statusEl.className = 'log-status ' + statusClass(item.statusCode);
+ statusEl.textContent = item.statusCode;
msgDiv.appendChild(statusEl);
- msgDiv.appendChild(document.createTextNode(' \u00b7 ')); // · separator
+ msgDiv.appendChild(document.createTextNode(' \u00b7 '));
}
- var msgText = document.createTextNode(l.msg);
- msgDiv.appendChild(msgText);
+ msgDiv.appendChild(document.createTextNode(item.message));
row.appendChild(msgDiv);
- var durEl = document.createElement('span');
- var durSlow = (l.duration.indexOf('s') !== -1 && parseFloat(l.duration) > 1);
- var durClass = durSlow ? 'slow' : (l.level === 'error' ? 'error' : '');
- durEl.className = 'log-duration' + (durClass ? ' ' + durClass : '');
- durEl.textContent = l.duration;
- row.appendChild(durEl);
+ var durationEl = document.createElement('span');
+ var durationClass = item.level === 'error' ? ' error' : (item.durationMs >= 1000 ? ' slow' : '');
+ durationEl.className = 'log-duration' + durationClass;
+ durationEl.textContent = durationLabel(item.durationMs);
+ row.appendChild(durationEl);
- frag.appendChild(row);
+ fragment.appendChild(row);
- // ── Expanded detail ──
- if (l.detail) {
- var expDiv = document.createElement('div');
- expDiv.className = 'log-entry-expanded' + (l.id === openId ? ' open' : '');
- expDiv.setAttribute('data-exp', l.id);
+ var expanded = document.createElement('div');
+ expanded.className = 'log-entry-expanded' + (item.id === state.openId ? ' open' : '');
+ expanded.setAttribute('data-exp', item.id);
- if (l.detail.req) {
- var reqLabel = document.createElement('div');
- reqLabel.className = 'log-detail-label';
- reqLabel.textContent = 'Request body';
- expDiv.appendChild(reqLabel);
+ if (item.id === state.openId) {
+ var detail = state.details[item.id] || item;
- var reqPre = document.createElement('pre');
- reqPre.className = 'log-detail-block';
- reqPre.textContent = l.detail.req;
- expDiv.appendChild(reqPre);
+ if (detail.agentDisplayName || detail.agentSlug) {
+ var agentLabel = document.createElement('div');
+ agentLabel.className = 'log-detail-label';
+ agentLabel.textContent = 'Agent';
+ expanded.appendChild(agentLabel);
+
+ var agentPre = document.createElement('pre');
+ agentPre.className = 'log-detail-block';
+ agentPre.textContent = detail.agentDisplayName || detail.agentSlug;
+ expanded.appendChild(agentPre);
}
- if (l.detail.res) {
- var resLabel = document.createElement('div');
- resLabel.className = 'log-detail-label';
- resLabel.textContent = 'Response';
- expDiv.appendChild(resLabel);
+ var requestLabel = document.createElement('div');
+ requestLabel.className = 'log-detail-label';
+ requestLabel.textContent = 'Request preview';
+ expanded.appendChild(requestLabel);
- var resPre = document.createElement('pre');
- resPre.className = 'log-detail-block';
- resPre.textContent = l.detail.res;
- expDiv.appendChild(resPre);
+ var requestPre = document.createElement('pre');
+ requestPre.className = 'log-detail-block';
+ requestPre.textContent = formatJson(detail.requestPreview);
+ expanded.appendChild(requestPre);
+
+ var responseLabel = document.createElement('div');
+ responseLabel.className = 'log-detail-label';
+ responseLabel.textContent = 'Response preview';
+ expanded.appendChild(responseLabel);
+
+ var responsePre = document.createElement('pre');
+ responsePre.className = 'log-detail-block';
+ responsePre.textContent = formatJson(detail.responsePreview);
+ expanded.appendChild(responsePre);
+
+ if (detail.errorKind || detail.requestId) {
+ var metaLabel = document.createElement('div');
+ metaLabel.className = 'log-detail-label';
+ metaLabel.textContent = 'Metadata';
+ expanded.appendChild(metaLabel);
+
+ var metaPre = document.createElement('pre');
+ metaPre.className = 'log-detail-block';
+ metaPre.textContent = formatJson({
+ request_id: detail.requestId || null,
+ error_kind: detail.errorKind || null,
+ source: detail.source,
+ status: detail.status,
+ });
+ expanded.appendChild(metaPre);
}
-
- frag.appendChild(expDiv);
}
+
+ fragment.appendChild(expanded);
});
logList.innerHTML = '';
- logList.appendChild(frag);
+ logList.appendChild(fragment);
- // Attach click handlers for expand/collapse
- logList.querySelectorAll('.log-entry').forEach(function (el) {
- el.addEventListener('click', function () {
- var id = parseInt(this.getAttribute('data-id'), 10);
- openId = (openId === id) ? null : id;
- logList.querySelectorAll('.log-entry-expanded').forEach(function (exp) {
- var expId = parseInt(exp.getAttribute('data-exp'), 10);
- exp.classList.toggle('open', expId === openId);
- });
- });
- });
- }
-
- // ── Level chip wiring ──────────────────────────────────────────────────────
-
- function wireChips() {
- document.querySelectorAll('.filter-chip[data-level]').forEach(function (btn) {
- btn.addEventListener('click', function () {
- levelFilter = this.getAttribute('data-level');
- document.querySelectorAll('.filter-chip[data-level]').forEach(function (b) {
- b.classList.remove('active');
- });
- this.classList.add('active');
+ logList.querySelectorAll('.log-entry').forEach(function (row) {
+ row.addEventListener('click', async function () {
+ var id = this.getAttribute('data-id');
+ state.openId = state.openId === id ? null : id;
renderLogs();
+ if (state.openId) {
+ await loadLogDetail(id);
+ }
});
});
}
- wireChips();
-
- // ── Search ─────────────────────────────────────────────────────────────────
-
- logSearch.addEventListener('input', function () {
- searchText = this.value;
- renderLogs();
- });
-
- // ── Refresh ────────────────────────────────────────────────────────────────
-
- refreshBtn.addEventListener('click', renderLogs);
-
- // ── Time-range select ──────────────────────────────────────────────────────
- // The HTML uses a with option values 30m/1h/6h/24h/7d.
- // Default the select to "7d" on page load.
-
- if (timeRangeSel) {
- // Set initial value to 7d
- timeRangeSel.value = '7d';
- timeRange = '7d';
-
- timeRangeSel.addEventListener('change', function () {
- timeRange = this.value;
- renderLogs();
- });
- }
-
- // ── Live mode ──────────────────────────────────────────────────────────────
-
- var LIVE_OPS = ['create_crm_lead', 'get_user_profile', 'fetch_invoice', 'list_products'];
- var LIVE_MSGS = {
- 'create_crm_lead': 'Tool invoked — POST /v1/leads',
- 'get_user_profile': 'Tool invoked — GET /v1/users/me',
- 'fetch_invoice': 'Tool invoked — GET /invoices/inv_live',
- 'list_products': 'Tool invoked — GET /api/products'
- };
- var LIVE_STATUSES = { 'info': 200, 'warn': 429, 'error': 503, 'debug': 200 };
-
- function pickLiveLevel() {
- var r = Math.random();
- if (r < 0.70) return 'info';
- if (r < 0.85) return 'warn';
- if (r < 0.95) return 'error';
- return 'debug';
- }
-
- function makeLiveEntry() {
- var op = LIVE_OPS[Math.floor(Math.random() * LIVE_OPS.length)];
- var level = pickLiveLevel();
- var status = LIVE_STATUSES[level];
- var duration = (level === 'error') ? '0ms' : (Math.floor(Math.random() * 400) + 20) + 'ms';
- nextId += 1;
- return {
- id: nextId,
- ts: new Date(),
- level: level,
- op: op,
- msg: LIVE_MSGS[op],
- duration: duration,
- status: status,
- detail: {
- req: '{"live":true}',
- res: level === 'error' ? status + ' Service Unavailable' : '{"ok":true}'
- }
+ function queryParams() {
+ var params = {
+ period: state.period,
+ limit: 100,
};
- }
-
- function setLivePulsing(on) {
- if (!liveDot) return;
- if (on) {
- liveDot.style.animationPlayState = 'running';
- liveDot.style.opacity = '';
- } else {
- liveDot.style.animationPlayState = 'paused';
- liveDot.style.opacity = '0.35';
+ if (state.level !== 'all') {
+ params.level = state.level;
}
+ if (state.search) {
+ params.search = state.search;
+ }
+ return params;
}
- function updateLiveLabel() {
- if (!liveLabel) return;
- liveLabel.textContent = liveMode ? 'Live' : 'Paused';
- liveLabel.style.color = liveMode ? '' : 'var(--text-muted)';
- }
-
- function startLive() {
- if (liveTimer) return;
- liveTimer = setInterval(function () {
- LOGS.unshift(makeLiveEntry());
- if (LOGS.length > 100) {
- LOGS.length = 100;
- }
+ async function loadLogs() {
+ if (!window.CrankApi) {
+ state.loadError = 'Admin API is not available';
renderLogs();
- }, 4000);
+ return;
+ }
+
+ state.workspaceId = currentWorkspaceId();
+ if (!state.workspaceId) {
+ state.loadError = 'Workspace is not selected';
+ renderLogs();
+ return;
+ }
+
+ state.loading = true;
+ state.loadError = '';
+ renderLogs();
+
+ try {
+ var response = await window.CrankApi.listLogs(state.workspaceId, queryParams());
+ state.logs = (response && response.items ? response.items : []).map(normalizeLog);
+ if (state.openId && !state.logs.some(function (item) { return item.id === state.openId; })) {
+ state.openId = null;
+ }
+ } catch (error) {
+ state.loadError = error.message || 'Failed to load logs';
+ } finally {
+ state.loading = false;
+ renderLogs();
+ }
}
- function stopLive() {
- if (liveTimer) {
- clearInterval(liveTimer);
- liveTimer = null;
+ async function loadLogDetail(logId) {
+ if (!window.CrankApi || !state.workspaceId || state.details[logId]) {
+ return;
}
+
+ try {
+ var record = await window.CrankApi.getLog(state.workspaceId, logId);
+ state.details[logId] = normalizeLog(record);
+ if (state.openId === logId) {
+ renderLogs();
+ }
+ } catch (_error) {
+ }
+ }
+
+ function setLiveState() {
+ if (liveDot) {
+ liveDot.style.animationPlayState = state.liveMode ? 'running' : 'paused';
+ liveDot.style.opacity = state.liveMode ? '' : '0.35';
+ liveDot.style.cursor = 'pointer';
+ }
+ if (liveLabel) {
+ liveLabel.textContent = state.liveMode ? 'Live' : 'Paused';
+ liveLabel.style.color = state.liveMode ? '' : 'var(--text-muted)';
+ liveLabel.style.cursor = 'pointer';
+ }
+ }
+
+ function stopPolling() {
+ if (state.timer) {
+ clearInterval(state.timer);
+ state.timer = null;
+ }
+ }
+
+ function startPolling() {
+ stopPolling();
+ if (!state.liveMode) {
+ return;
+ }
+ state.timer = setInterval(loadLogs, 4000);
}
function toggleLive() {
- liveMode = !liveMode;
- setLivePulsing(liveMode);
- updateLiveLabel();
- if (liveMode) {
- startLive();
- } else {
- stopLive();
- }
+ state.liveMode = !state.liveMode;
+ setLiveState();
+ startPolling();
+ }
+
+ document.querySelectorAll('.filter-chip[data-level]').forEach(function (button) {
+ button.addEventListener('click', function () {
+ state.level = this.getAttribute('data-level');
+ document.querySelectorAll('.filter-chip[data-level]').forEach(function (item) {
+ item.classList.remove('active');
+ });
+ this.classList.add('active');
+ loadLogs();
+ });
+ });
+
+ if (logSearch) {
+ logSearch.addEventListener('input', function () {
+ state.search = this.value.trim();
+ loadLogs();
+ });
+ }
+
+ if (refreshBtn) {
+ refreshBtn.addEventListener('click', loadLogs);
+ }
+
+ if (timeRangeSel) {
+ timeRangeSel.value = state.period;
+ timeRangeSel.addEventListener('change', function () {
+ state.period = this.value;
+ loadLogs();
+ });
}
- // Wire live toggle to clicking the dot or the label
if (liveDot) {
- liveDot.style.cursor = 'pointer';
liveDot.addEventListener('click', toggleLive);
}
+
if (liveLabel) {
- liveLabel.style.cursor = 'pointer';
liveLabel.addEventListener('click', toggleLive);
}
- // Also support a dedicated live-toggle button if one exists in the DOM
- var liveToggleBtn = document.getElementById('live-toggle') || document.querySelector('.live-btn');
- if (liveToggleBtn) {
- liveToggleBtn.addEventListener('click', toggleLive);
+ window.addEventListener('crank:workspacechange', function () {
+ state.details = {};
+ state.openId = null;
+ loadLogs();
+ });
+
+ setLiveState();
+ startPolling();
+
+ if (window.whenWorkspacesReady) {
+ window.whenWorkspacesReady().finally(loadLogs);
+ } else {
+ loadLogs();
}
-
- // ── Initialise ─────────────────────────────────────────────────────────────
-
- setLivePulsing(true);
- updateLiveLabel();
- startLive();
- renderLogs();
-
});
diff --git a/apps/ui/js/usage.js b/apps/ui/js/usage.js
index 11d26bb..d220e3e 100644
--- a/apps/ui/js/usage.js
+++ b/apps/ui/js/usage.js
@@ -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 = ' ';
- var ARROW_DOWN = ' ';
-
- // ── 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 = '' + errRate + '% ';
-
- 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 = '' + message + '
';
+ tableBody.innerHTML = '' + message + ' ';
+ }
+
+ 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 = 'No usage data for selected period
';
+ 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 = 'No operation usage data for selected period ';
+ 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();
+ }
});
diff --git a/docs/alpine-ui-integration-plan.md b/docs/alpine-ui-integration-plan.md
index 91035c9..4f928fe 100644
--- a/docs/alpine-ui-integration-plan.md
+++ b/docs/alpine-ui-integration-plan.md
@@ -236,15 +236,14 @@ UI-файлы:
Что еще не хватает:
-- frontend adapter с трансформацией backend response в текущую log-row форму;
-- polling strategy вместо локального seeded массива;
-- явная поддержка `debug/info/warn/error` в UI без локального seed;
-- если нужен live mode, то либо polling, либо отдельный future streaming endpoint
+- optional pagination и server-driven cursor, если логи начнут расти;
+- отдельный streaming endpoint, если polling перестанет устраивать;
+- richer detail metadata, если захотим показывать trace/request ids отдельными виджетами
Простой итог:
-- backend foundation для logs уже есть;
-- страница интегрируется быстро, если заменить seeded `LOGS` на API polling.
+- страница уже подключена к live `logs` API;
+- текущий `live mode` реализован через polling и этого достаточно для текущего этапа.
### 4.6. Usage
@@ -270,14 +269,14 @@ UI-файлы:
Что еще не хватает:
-- frontend mapping из backend `summary/timeline/operations/agents` в текущие widgets;
-- `CSV export` endpoint или серверная договоренность, если экспорт не хотим делать на клиенте;
-- согласование полей latency percentiles в одном формате
+- если понадобится agent-specific usage drilldown, для него нужен отдельный экран или таб;
+- если понадобится server-side export, можно добавить отдельный CSV endpoint позже;
+- quota/limits пока не являются частью backend модели, поэтому страница честно показывает traffic share
Простой итог:
-- usage page можно подключать сразу после logs;
-- главная работа здесь на frontend adapter, а не на новом backend-ядре.
+- usage page уже подключена к live `usage` API;
+- CSV export сейчас делается на клиенте и этого достаточно для текущего этапа.
### 4.7. Workspace setup