From 7aabf7077a0dc8c3de1ed641dd7f073b96245a8a Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Tue, 31 Mar 2026 16:32:59 +0300 Subject: [PATCH 1/2] feat: polish alpine settings and wizard feedback --- TASKS.md | 10 +- apps/ui/css/wizard.css | 36 +++++ apps/ui/html/settings.html | 288 ++++----------------------------- apps/ui/html/wizard/step5.html | 11 ++ apps/ui/js/settings.js | 97 ++++------- apps/ui/js/wizard.js | 61 +++++-- 6 files changed, 167 insertions(+), 336 deletions(-) diff --git a/TASKS.md b/TASKS.md index e17b96b..62aeb5e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,14 +2,14 @@ ## Current -### `feat/platform-key-usage` +### `feat/alpine-polish` -Status: completed +Status: in_progress DoD: -- platform API keys authenticate real machine access -- `last_used_at` updates from successful platform key usage -- UI API keys page reflects honest runtime usage rather than seed-only metadata +- Alpine UI pages have consistent feedback and shell state +- Settings page exposes only honest live functionality and clearly marks deferred capabilities +- Wizard live actions have stable loading and feedback behavior on real backend requests ## Next diff --git a/apps/ui/css/wizard.css b/apps/ui/css/wizard.css index 0cfa730..f99a4b3 100644 --- a/apps/ui/css/wizard.css +++ b/apps/ui/css/wizard.css @@ -654,6 +654,21 @@ border-radius: 8px; } +.info-callout.info-callout-subtle { + background: rgba(148, 163, 184, 0.08); + border-color: rgba(148, 163, 184, 0.18); +} + +.info-callout.is-success { + background: rgba(13, 148, 136, 0.07); + border-color: rgba(13, 148, 136, 0.28); +} + +.info-callout.is-error { + background: rgba(248, 113, 113, 0.08); + border-color: rgba(248, 113, 113, 0.28); +} + .info-callout-icon { width: 18px; height: 18px; @@ -662,6 +677,19 @@ color: var(--accent); } +.info-callout.info-callout-subtle .info-callout-icon { + color: var(--text-secondary); +} + +.info-callout.is-error .info-callout-icon, +.info-callout.is-error .info-callout-title { + color: #f87171; +} + +.info-callout.is-success .info-callout-title { + color: var(--accent-bright); +} + .info-callout-body { flex: 1; } .info-callout-title { @@ -1404,6 +1432,14 @@ } .btn-ghost-sm:hover { border-color: var(--text-secondary); color: var(--text-primary); } +.btn-primary-sm:disabled, +.btn-ghost-sm:disabled, +.btn-primary-sm.is-busy, +.btn-ghost-sm.is-busy { + opacity: 0.68; + cursor: wait; +} + /* ══════════════════════════════════════════════ HTTP method picker (Step 4 REST) ══════════════════════════════════════════════ */ diff --git a/apps/ui/html/settings.html b/apps/ui/html/settings.html index 118c960..b2f1663 100644 --- a/apps/ui/html/settings.html +++ b/apps/ui/html/settings.html @@ -96,7 +96,7 @@ @@ -215,10 +215,10 @@
Operator
operator@acme-workspace
-
Profile photo upload is not available yet.
+
Your avatar is generated from your display name and email.
-
Your name and email are stored on the backend and used across the console.
+
Your name and email are stored on the backend and used across the console and session identity.
@@ -245,196 +245,6 @@
- - - - -
@@ -464,8 +274,15 @@
-
Advanced security
-
Two-factor authentication, passkeys and session management are not implemented yet. The current live security flow is password-based login with HttpOnly session cookies.
+
Planned security capabilities
+
+ + + +
+ Not available in this build. Two-factor authentication, passkeys and multi-session inventory are planned, but the current live flow is password-based login with one HttpOnly browser session. +
+
@@ -477,7 +294,7 @@
Browser session — current
-
Session expires according to server-side auth settings. Use Log out to revoke the current browser session.
+
Loading current session details…
@@ -491,73 +308,28 @@
Notification preferences
-
-
-
Operation error spike
-
Alert when error rate exceeds 5% over a 5-minute window.
+
+ + + + +
+ Notifications are not wired yet. Alert routing and per-user preferences are planned, but this build does not persist or deliver notification settings.
-
-
-
-
Upstream latency degradation
-
Alert when p99 latency exceeds the baseline by 3×.
+
+
+
Operation error spike
+
Alert when error rate exceeds a rolling threshold for a published tool.
-
-
-
-
-
Quota approaching limit
-
Email when any operation reaches 80% of its monthly quota.
+
+
Upstream latency degradation
+
Notify operators when p99 latency rises sharply above the baseline.
-
-
-
-
-
New team member joined
-
Notify workspace admins when someone accepts an invite.
+
+
Quota and usage digests
+
Deliver periodic summaries for quota consumption, errors and workspace activity.
-
-
-
-
-
Weekly usage digest
-
Email summary of invocations, errors and latency each Monday.
-
-
-
-
Notification preferences are local UI placeholders for now and are not synced to the backend yet.
-
-
-
- - -
+
+ + + + +
+
Live actions save the draft first
+
Sample uploads, test runs, YAML actions, publish and gRPC descriptor discovery all persist the current draft before calling the backend.
+
+
+
diff --git a/apps/ui/js/settings.js b/apps/ui/js/settings.js index 31b5dce..782c114 100644 --- a/apps/ui/js/settings.js +++ b/apps/ui/js/settings.js @@ -5,16 +5,6 @@ function settingsWorkspaceId() { return workspace ? workspace.id : null; } -function downloadSettingsJson(fileName, value) { - var blob = new Blob([JSON.stringify(value, null, 2)], { type: 'application/json;charset=utf-8' }); - var url = URL.createObjectURL(blob); - var link = document.createElement('a'); - link.href = url; - link.download = fileName; - link.click(); - URL.revokeObjectURL(url); -} - function initials(displayName, email) { var source = displayName || email || 'Crank'; return source @@ -34,6 +24,37 @@ function setStatus(id, text, isError) { : 'var(--text-muted)'; } +function titleCaseRole(role) { + return String(role || 'viewer').replace(/^\w/, function(character) { + return character.toUpperCase(); + }); +} + +function populateCurrentSession(session) { + var summary = document.getElementById('settings-session-summary'); + if (!summary) { + return; + } + + if (!(session && session.user)) { + summary.textContent = 'Session details are unavailable. Use Log out to revoke the current browser session.'; + return; + } + + var membership = null; + if (Array.isArray(session.memberships)) { + membership = session.memberships.find(function(item) { + return item.workspace && item.workspace.id === session.current_workspace_id; + }) || session.memberships[0] || null; + } + + var workspaceLabel = membership && membership.workspace + ? (membership.workspace.display_name || membership.workspace.slug || membership.workspace.id) + : 'current workspace'; + var roleLabel = membership ? titleCaseRole(membership.role) : 'Viewer'; + summary.textContent = session.user.email + ' · ' + roleLabel + ' in ' + workspaceLabel + '. Use Log out to revoke the current browser session.'; +} + function populateProfile(session) { if (!session || !session.user) { return; @@ -52,6 +73,8 @@ function populateProfile(session) { if (email) { email.value = user.email || ''; } + + populateCurrentSession(session); } async function loadProfile() { @@ -285,65 +308,11 @@ async function loadWorkspaceSettings() { } } -function bindDangerZoneActions() { - var renameButton = document.getElementById('settings-ws-rename-btn'); - var exportButton = document.getElementById('settings-ws-export-btn'); - var deleteButton = document.getElementById('settings-ws-delete-btn'); - - if (renameButton && renameButton.dataset.bound !== 'true') { - renameButton.dataset.bound = 'true'; - renameButton.addEventListener('click', function() { - document.getElementById('settings-ws-slug').focus(); - if (window.CrankUi) { - window.CrankUi.info('Update the workspace slug above and save changes to rename it.', 'Rename workspace'); - } - }); - } - - if (exportButton && exportButton.dataset.bound !== 'true') { - exportButton.dataset.bound = 'true'; - exportButton.addEventListener('click', async function() { - var workspaceId = settingsWorkspaceId(); - if (!workspaceId || !window.CrankApi) return; - try { - var snapshot = await window.CrankApi.exportWorkspace(workspaceId); - var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; - downloadSettingsJson((workspace ? workspace.slug : 'workspace') + '-snapshot.json', snapshot); - if (window.CrankUi) { - window.CrankUi.success('Workspace export downloaded.', 'Export complete'); - } - } catch (error) { - if (window.CrankUi) { - window.CrankUi.error(error.message || 'Failed to export workspace', 'Export failed'); - } - } - }); - } - - if (deleteButton && deleteButton.dataset.bound !== 'true') { - deleteButton.dataset.bound = 'true'; - deleteButton.addEventListener('click', async function() { - var workspaceId = settingsWorkspaceId(); - if (!workspaceId || !window.CrankApi || !window.CrankAuth) return; - if (!window.confirm('Delete workspace? This cannot be undone.')) return; - try { - await window.CrankApi.deleteWorkspace(workspaceId); - await window.CrankAuth.logout(); - } catch (error) { - if (window.CrankUi) { - window.CrankUi.error(error.message || 'Failed to delete workspace', 'Delete failed'); - } - } - }); - } -} - document.addEventListener('DOMContentLoaded', function () { injectLanguageSwitcher(); bindSectionNavigation(); loadProfile(); bindProfileSave(); bindPasswordSave(); - bindDangerZoneActions(); loadWorkspaceSettings(); }); diff --git a/apps/ui/js/wizard.js b/apps/ui/js/wizard.js index 5c4fb11..b82ae3e 100644 --- a/apps/ui/js/wizard.js +++ b/apps/ui/js/wizard.js @@ -1352,19 +1352,19 @@ function prefillWizardFromEdit(detail, versionDocument) { } function bindWizardLiveActions() { - bindClick('wizard-upload-input-sample', uploadInputSampleFromWizard); - bindClick('wizard-upload-output-sample', uploadOutputSampleFromWizard); - bindClick('wizard-generate-draft', generateDraftFromWizard); - bindClick('wizard-run-test', runWizardTest); + bindLiveAction('wizard-upload-input-sample', 'Saving input sample…', uploadInputSampleFromWizard); + bindLiveAction('wizard-upload-output-sample', 'Saving output sample…', uploadOutputSampleFromWizard); + bindLiveAction('wizard-generate-draft', 'Generating draft…', generateDraftFromWizard); + bindLiveAction('wizard-run-test', 'Running test…', runWizardTest); bindClick('wizard-copy-test-response', copyTestResponseToOutputSample); - bindClick('wizard-export-yaml', exportWizardYaml); - bindClick('wizard-import-yaml', importWizardYaml); - bindClick('wizard-publish-operation', publishWizardOperation); + bindLiveAction('wizard-export-yaml', 'Exporting YAML…', exportWizardYaml); + bindLiveAction('wizard-import-yaml', 'Importing YAML…', importWizardYaml); + bindLiveAction('wizard-publish-operation', 'Publishing operation…', publishWizardOperation); bindClick('wizard-select-descriptor-set', function() { var input = document.getElementById('wizard-descriptor-set-input'); if (input) input.click(); }); - bindClick('wizard-upload-descriptor-set', uploadDescriptorSetAndDiscover); + bindLiveAction('wizard-upload-descriptor-set', 'Uploading descriptor set…', uploadDescriptorSetAndDiscover); bindClick('wizard-import-yaml-file-trigger', function() { var input = document.getElementById('wizard-import-yaml-file'); if (input) input.click(); @@ -1386,6 +1386,48 @@ function bindClick(id, handler) { }); } +function bindLiveAction(id, busyLabel, handler) { + var element = document.getElementById(id); + if (!element) return; + element.addEventListener('click', function(event) { + event.preventDefault(); + runWizardLiveAction(element, busyLabel, handler); + }); +} + +async function runWizardLiveAction(button, busyLabel, handler) { + if (!button || button.dataset.busy === 'true') { + return; + } + + var originalLabel = button.textContent; + button.dataset.busy = 'true'; + button.disabled = true; + button.classList.add('is-busy'); + button.textContent = busyLabel; + + try { + await handler(); + } catch (error) { + showWizardLiveStatus( + 'Live action failed', + error && error.message ? error.message : 'The backend request failed. Review the draft and try again.', + true + ); + if (window.CrankUi) { + window.CrankUi.error( + error && error.message ? error.message : 'The backend request failed. Review the draft and try again.', + 'Wizard action failed' + ); + } + } finally { + button.dataset.busy = 'false'; + button.disabled = false; + button.classList.remove('is-busy'); + button.textContent = originalLabel; + } +} + function updateWizardProtocolVisibility() { var grpcTools = document.getElementById('wizard-grpc-live-tools'); if (grpcTools) grpcTools.style.display = wizardProtocol === 'grpc' ? '' : 'none'; @@ -1399,7 +1441,8 @@ function showWizardLiveStatus(title, text, isError) { titleEl.textContent = title; textEl.textContent = text; root.style.display = ''; - root.style.borderColor = isError ? 'rgba(248, 113, 113, 0.35)' : ''; + root.classList.toggle('is-error', !!isError); + root.classList.toggle('is-success', !isError); } function currentDraftVersion() { From ffa9cc87eba759724bed02819d9a89556b164f2a Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Tue, 31 Mar 2026 17:03:26 +0300 Subject: [PATCH 2/2] feat: polish alpine live page states --- apps/ui/html/logs.html | 1 + apps/ui/html/usage.html | 1 + apps/ui/index.html | 8 ++++---- apps/ui/js/agents.js | 30 +++++++++++++++++++++++++++++- apps/ui/js/api-keys.js | 29 +++++++++++++++++++++++++++++ apps/ui/js/catalog.js | 7 +++++++ apps/ui/js/logs.js | 33 ++++++++++++++++++++++++++------- apps/ui/js/usage.js | 40 +++++++++++++++++++++++++++++++++------- 8 files changed, 130 insertions(+), 19 deletions(-) diff --git a/apps/ui/html/logs.html b/apps/ui/html/logs.html index d8937a1..d881319 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 c653157..e00ea12 100644 --- a/apps/ui/html/usage.html +++ b/apps/ui/html/usage.html @@ -13,6 +13,7 @@ + diff --git a/apps/ui/index.html b/apps/ui/index.html index 795152b..5428888 100644 --- a/apps/ui/index.html +++ b/apps/ui/index.html @@ -319,12 +319,12 @@
-
-

No operations found

-

Try adjusting your search or filters.

+
+

No operations found

+

Try adjusting your search or filters.

-
+

Backend connection failed

diff --git a/apps/ui/js/agents.js b/apps/ui/js/agents.js index f7dd1bb..125c926 100644 --- a/apps/ui/js/agents.js +++ b/apps/ui/js/agents.js @@ -288,6 +288,12 @@ document.addEventListener('alpine:init', function() { } await this.reload(); + if (window.CrankUi) { + window.CrankUi.success( + this.form.display_name + ' was saved with ' + this.form.selectedOps.length + ' tool bindings.', + this.drawerMode === 'create' ? 'Agent created' : 'Agent updated' + ); + } this.closeDrawer(); } catch (error) { if (window.CrankUi) { @@ -301,8 +307,15 @@ document.addEventListener('alpine:init', function() { if (!confirm('Delete this agent? This cannot be undone.')) return; try { + var agent = this.agents.find(function(item) { return item.id === id; }); await window.CrankApi.deleteAgent(this.workspaceId, id); await this.reload(); + if (window.CrankUi) { + window.CrankUi.success( + (agent ? agent.display_name : 'Agent') + ' was deleted.', + 'Agent deleted' + ); + } } catch (error) { if (window.CrankUi) { window.CrankUi.error(error.message || 'Failed to delete agent', 'Delete failed'); @@ -326,6 +339,17 @@ document.addEventListener('alpine:init', function() { await window.CrankApi.archiveAgent(this.workspaceId, agent.id); } await this.reload(); + if (window.CrankUi) { + var actionLabel = action === 'publish' + ? 'published' + : action === 'unpublish' + ? 'returned to draft' + : 'archived'; + window.CrankUi.success( + agent.display_name + ' was ' + actionLabel + '.', + 'Agent lifecycle updated' + ); + } } catch (error) { if (window.CrankUi) { window.CrankUi.error(error.message || 'Failed to update agent lifecycle', 'Lifecycle update failed'); @@ -357,7 +381,11 @@ document.addEventListener('alpine:init', function() { }, copyEndpoint(agent) { - navigator.clipboard.writeText(this.mcpEndpoint(agent)).catch(function() {}); + var endpoint = this.mcpEndpoint(agent); + navigator.clipboard.writeText(endpoint).catch(function() {}); + if (window.CrankUi) { + window.CrankUi.info(endpoint, 'MCP endpoint copied'); + } }, statusClass(status) { diff --git a/apps/ui/js/api-keys.js b/apps/ui/js/api-keys.js index d8ac141..e551181 100644 --- a/apps/ui/js/api-keys.js +++ b/apps/ui/js/api-keys.js @@ -69,8 +69,15 @@ async function revokeKey(id) { if (!confirm('Revoke this key? All requests using it will fail immediately.')) return; try { + var key = KEYS.find(function(item) { return item.id === id; }); await window.CrankApi.revokePlatformApiKey(currentWorkspaceId, id); await loadKeys(); + if (window.CrankUi) { + window.CrankUi.success( + (key ? key.name : 'API key') + ' was revoked.', + 'Key revoked' + ); + } } catch (error) { if (window.CrankUi) { window.CrankUi.error(error.message || 'Failed to revoke key', 'Revoke failed'); @@ -82,8 +89,15 @@ async function deleteKey(id) { if (!confirm('Delete this key? This cannot be undone.')) return; try { + var key = KEYS.find(function(item) { return item.id === id; }); await window.CrankApi.deletePlatformApiKey(currentWorkspaceId, id); await loadKeys(); + if (window.CrankUi) { + window.CrankUi.success( + (key ? key.name : 'API key') + ' was deleted.', + 'Key deleted' + ); + } } catch (error) { if (window.CrankUi) { window.CrankUi.error(error.message || 'Failed to delete key', 'Delete failed'); @@ -229,6 +243,7 @@ modal.addEventListener('click', function(event) { }); document.getElementById('modal-confirm-btn').addEventListener('click', async function() { + var button = this; var name = document.getElementById('new-key-name').value.trim(); if (!name) { document.getElementById('new-key-name').focus(); @@ -242,6 +257,8 @@ document.getElementById('modal-confirm-btn').addEventListener('click', async fun } try { + button.disabled = true; + button.textContent = 'Creating…'; var created = await createKey(name, Array.from(selectedScopes)); KEYS.unshift(created.record); document.getElementById('reveal-key-value').textContent = created.rawKey; @@ -250,10 +267,16 @@ document.getElementById('modal-confirm-btn').addEventListener('click', async fun document.getElementById('modal-footer-create').style.display = 'none'; document.getElementById('modal-footer-done').style.display = ''; renderTable(); + if (window.CrankUi) { + window.CrankUi.success('Copy the key now. It will not be shown again.', 'API key created'); + } } catch (error) { if (window.CrankUi) { window.CrankUi.error(error.message || 'Failed to create key', 'Key creation failed'); } + } finally { + button.disabled = false; + button.textContent = 'Create key'; } }); @@ -265,6 +288,9 @@ document.getElementById('copy-key-btn').addEventListener('click', function() { navigator.clipboard.writeText(value).catch(function() {}); } this.innerHTML = ''; + if (window.CrankUi) { + window.CrankUi.info('Store the raw key securely. It cannot be revealed again.', 'API key copied'); + } }); document.getElementById('key-search').addEventListener('input', function() { @@ -276,6 +302,9 @@ function copyPrefix(prefix) { if (navigator.clipboard) { navigator.clipboard.writeText(prefix).catch(function() {}); } + if (window.CrankUi) { + window.CrankUi.info(prefix, 'Key prefix copied'); + } } document.addEventListener('DOMContentLoaded', async function() { diff --git a/apps/ui/js/catalog.js b/apps/ui/js/catalog.js index 112fae2..d4c6f16 100644 --- a/apps/ui/js/catalog.js +++ b/apps/ui/js/catalog.js @@ -362,12 +362,19 @@ document.addEventListener('alpine:init', function() { async deleteOperation(id) { if (!confirm('Delete this operation? This cannot be undone.')) return; try { + var operation = this.operations.find(function(item) { return item.id === id; }); await window.CrankApi.deleteOperation(this.workspaceId, id); this.operations = this.operations.filter(function(operation) { return operation.id !== id; }); this.categoryOptions = Array.from(new Set(this.operations.map(function(operation) { return operation.category; }).filter(Boolean))).sort(); this.stats = computeStats(this.operations); + if (window.CrankUi) { + window.CrankUi.success( + (operation ? (operation.display_name || operation.name) : 'Operation') + ' was deleted.', + 'Operation deleted' + ); + } } catch (error) { if (window.CrankUi) { window.CrankUi.error(error.message || 'Failed to delete operation', 'Delete failed'); diff --git a/apps/ui/js/logs.js b/apps/ui/js/logs.js index 9d0bbe0..0ee9ae0 100644 --- a/apps/ui/js/logs.js +++ b/apps/ui/js/logs.js @@ -86,27 +86,34 @@ document.addEventListener('DOMContentLoaded', function () { }; } - function renderEmpty(message) { + function renderEmpty(title, 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; + empty.className = 'empty-state'; + empty.innerHTML = + '
' + title + '
' + + '
' + message + '
'; logList.appendChild(empty); } function renderLogs() { if (state.loading && state.logs.length === 0) { - renderEmpty('Loading logs...'); + renderEmpty('Loading logs…', 'Fetching invocation history for the current workspace.'); return; } if (state.loadError) { - renderEmpty(state.loadError); + renderEmpty('Unable to load logs', state.loadError); return; } if (!state.logs.length) { - renderEmpty('No log entries match the current filter'); + renderEmpty( + 'No log entries found', + state.search || state.level !== 'all' + ? 'Try widening the period or clearing the current search and level filters.' + : 'Run tests or published MCP tools to start collecting invocation history.' + ); return; } @@ -323,6 +330,12 @@ document.addEventListener('DOMContentLoaded', function () { state.liveMode = !state.liveMode; setLiveState(); startPolling(); + if (window.CrankUi) { + window.CrankUi.info( + state.liveMode ? 'Polling logs every 4 seconds.' : 'Automatic polling is paused.', + state.liveMode ? 'Live mode enabled' : 'Live mode paused' + ); + } } document.querySelectorAll('.filter-chip[data-level]').forEach(function (button) { @@ -344,7 +357,13 @@ document.addEventListener('DOMContentLoaded', function () { } if (refreshBtn) { - refreshBtn.addEventListener('click', loadLogs); + refreshBtn.addEventListener('click', function () { + loadLogs().then(function () { + if (!state.loadError && window.CrankUi) { + window.CrankUi.info('The latest invocation records were loaded for the current workspace.', 'Logs refreshed'); + } + }); + }); } if (timeRangeSel) { diff --git a/apps/ui/js/usage.js b/apps/ui/js/usage.js index d220e3e..0288d67 100644 --- a/apps/ui/js/usage.js +++ b/apps/ui/js/usage.js @@ -76,9 +76,19 @@ document.addEventListener('DOMContentLoaded', function () { return 'Wk ' + (index + 1); } - function renderEmpty(message) { - chartWrap.innerHTML = '
' + message + '
'; - tableBody.innerHTML = '' + message + ''; + function renderEmpty(title, message) { + chartWrap.innerHTML = + '
' + + '
' + title + '
' + + '
' + message + '
' + + '
'; + tableBody.innerHTML = + '' + + '
' + + '
' + title + '
' + + '
' + message + '
' + + '
' + + ''; } function renderStats() { @@ -119,7 +129,11 @@ document.addEventListener('DOMContentLoaded', function () { chartWrap.innerHTML = ''; if (!timeline.length) { - chartWrap.innerHTML = '
No usage data for selected period
'; + chartWrap.innerHTML = + '
' + + '
No usage data yet
' + + '
Invocation metrics will appear here after tests or published tool calls in the selected period.
' + + '
'; return; } @@ -145,7 +159,13 @@ document.addEventListener('DOMContentLoaded', function () { tableBody.innerHTML = ''; if (!operations.length) { - tableBody.innerHTML = 'No operation usage data for selected period'; + tableBody.innerHTML = + '' + + '
' + + '
No operation breakdown yet
' + + '
The selected period has no invocation samples to break down by operation.
' + + '
' + + ''; return; } @@ -194,12 +214,12 @@ document.addEventListener('DOMContentLoaded', function () { function renderUsage() { if (state.loading && !state.usage) { - renderEmpty('Loading usage...'); + renderEmpty('Loading usage…', 'Aggregating invocation metrics for the selected workspace.'); return; } if (state.loadError) { - renderEmpty(state.loadError); + renderEmpty('Unable to load usage', state.loadError); return; } @@ -241,6 +261,9 @@ document.addEventListener('DOMContentLoaded', function () { function exportCsv() { if (!state.usage) { + if (window.CrankUi) { + window.CrankUi.info('Load usage data before exporting the CSV snapshot.', 'No usage data loaded'); + } return; } @@ -268,6 +291,9 @@ document.addEventListener('DOMContentLoaded', function () { link.download = 'crank-usage-' + state.period + '.csv'; link.click(); URL.revokeObjectURL(url); + if (window.CrankUi) { + window.CrankUi.success('The current usage snapshot was exported as CSV.', 'Usage exported'); + } } if (periodSelect) {