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 @@ Errors Error rate Latency (p50 / p95 / p99) - Quota + Traffic share 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