merge: alpine polish

This commit is contained in:
a.tolmachev
2026-03-31 17:10:30 +03:00
14 changed files with 297 additions and 355 deletions
+5 -5
View File
@@ -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
+36
View File
@@ -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)
══════════════════════════════════════════════ */
+1
View File
@@ -13,6 +13,7 @@
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/api.js"></script>
<script src="js/ui-feedback.js"></script>
<script src="js/workspace.js"></script>
<script src="js/auth.js"></script>
<script>window.CrankAuth.guardProtectedPage();</script>
+30 -258
View File
@@ -96,7 +96,7 @@
<div class="page-header" style="margin-bottom: 24px;">
<div class="page-header-text">
<h1 class="page-title">Account settings</h1>
<p class="page-subtitle">Manage your personal profile, security, and notification preferences.</p>
<p class="page-subtitle">Manage the live profile, password and workspace settings available in this build. Planned capabilities stay visible, but they do not pretend to be wired yet.</p>
</div>
</div>
@@ -215,10 +215,10 @@
<div class="avatar-upload-actions">
<div class="avatar-name" id="profile-display-name">Operator</div>
<div class="avatar-sub" id="profile-email">operator@acme-workspace</div>
<div class="field-hint">Profile photo upload is not available yet.</div>
<div class="field-hint">Your avatar is generated from your display name and email.</div>
</div>
</div>
<div class="field-hint" style="margin-bottom:16px;">Your name and email are stored on the backend and used across the console.</div>
<div class="field-hint" style="margin-bottom:16px;">Your name and email are stored on the backend and used across the console and session identity.</div>
<div class="field-row">
<div class="field-group">
@@ -245,196 +245,6 @@
</div>
</div>
<!-- ■ MEMBERS — moved to workspace-setup.html ■ -->
<div id="section-members" style="display:none;">
<!-- Members card -->
<div class="section-card" style="margin-bottom: 16px;">
<div class="section-card-header">
<div>
<div class="section-card-title">Team members</div>
<div class="section-card-subtitle" id="members-count-label">4 members</div>
</div>
<button class="btn-primary" type="button" style="padding:7px 14px;font-size:13px;" onclick="toggleInviteForm()">
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" style="margin-right:4px;"><path d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 010 1.5H8.5v4.25a.75.75 0 01-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
Invite member
</button>
</div>
<!-- Invite form -->
<div id="invite-form" style="display:none; padding: 0 20px 16px; border-bottom: 1px solid var(--border-subtle);">
<div style="display:flex; gap:10px; align-items:flex-end; flex-wrap:wrap;">
<div class="field-group" style="flex:1; min-width:200px; margin-bottom:0;">
<label class="field-label">Email address</label>
<input class="field-input" id="invite-email" type="email" placeholder="colleague@company.com" autocomplete="off">
</div>
<div class="field-group" style="width:150px; margin-bottom:0;">
<label class="field-label">Role</label>
<select class="field-input" id="invite-role">
<option value="admin">Admin</option>
<option value="developer" selected>Developer</option>
<option value="viewer">Viewer</option>
</select>
</div>
<button class="btn-primary" type="button" style="padding:7px 14px;font-size:13px;white-space:nowrap;" onclick="sendInvite()">Send invite</button>
<button class="btn-secondary" type="button" style="padding:7px 12px;font-size:13px;" onclick="toggleInviteForm()">Cancel</button>
</div>
<div id="invite-success" style="display:none; margin-top:10px; font-size:13px; color: #3fb950;">
✓ Invite sent successfully.
</div>
</div>
<!-- Members list -->
<div class="section-card-body" style="padding: 4px 20px 8px;" id="members-list">
<!-- Owner row (you) -->
<div class="member-row">
<div class="member-avatar" style="background:linear-gradient(135deg,#0d9488,#6366f1);">AT</div>
<div class="member-info">
<div class="member-name">Operator <span style="font-size:11px;font-weight:400;color:var(--text-muted);">(you)</span></div>
<div class="member-email">operator@acme.com</div>
</div>
<div class="member-last-active">Today</div>
<span class="member-role-badge role-owner">Owner</span>
</div>
<!-- Admin -->
<div class="member-row">
<div class="member-avatar" style="background:linear-gradient(135deg,#7c3aed,#4f46e5);">BS</div>
<div class="member-info">
<div class="member-name">Blake Smith</div>
<div class="member-email">blake@acme.com</div>
</div>
<div class="member-last-active">2 days ago</div>
<select class="member-role-select" onchange="updateRole(this, 'blake@acme.com')">
<option value="admin" selected>Admin</option>
<option value="developer">Developer</option>
<option value="viewer">Viewer</option>
</select>
<button class="member-remove-btn" onclick="removeMember(this, 'Blake Smith')" title="Remove member">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
</button>
</div>
<!-- Developer -->
<div class="member-row">
<div class="member-avatar" style="background:linear-gradient(135deg,#d29922,#f59e0b);">CL</div>
<div class="member-info">
<div class="member-name">Carol Lane</div>
<div class="member-email">carol@acme.com</div>
</div>
<div class="member-last-active">1 week ago</div>
<select class="member-role-select" onchange="updateRole(this, 'carol@acme.com')">
<option value="admin">Admin</option>
<option value="developer" selected>Developer</option>
<option value="viewer">Viewer</option>
</select>
<button class="member-remove-btn" onclick="removeMember(this, 'Carol Lane')" title="Remove member">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
</button>
</div>
<!-- Viewer -->
<div class="member-row">
<div class="member-avatar" style="background:linear-gradient(135deg,#0891b2,#06b6d4);">DM</div>
<div class="member-info">
<div class="member-name">Dan Morgan</div>
<div class="member-email">dan@acme.com</div>
</div>
<div class="member-last-active">Never</div>
<select class="member-role-select" onchange="updateRole(this, 'dan@acme.com')">
<option value="admin">Admin</option>
<option value="developer">Developer</option>
<option value="viewer" selected>Viewer</option>
</select>
<button class="member-remove-btn" onclick="removeMember(this, 'Dan Morgan')" title="Remove member">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
</button>
</div>
</div>
<!-- Pending invites -->
<div id="pending-invites" style="border-top: 1px solid var(--border-subtle); padding: 12px 20px 16px;">
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-muted);margin-bottom:10px;">Pending invites <span id="pending-count" style="background:rgba(139,148,158,0.15);border-radius:10px;padding:1px 7px;font-size:10px;">1</span></div>
<div class="member-row" id="invite-john">
<div class="member-avatar" style="background:var(--bg-canvas);border:1.5px dashed var(--border);color:var(--text-muted);font-size:16px;">?</div>
<div class="member-info">
<div class="member-name">john@startup.com</div>
<div class="member-email">Invited 3 days ago · not yet accepted</div>
</div>
<div class="member-last-active"></div>
<span class="member-role-badge" style="background:rgba(139,148,158,0.12);color:var(--text-muted);border:1px solid var(--border);">Developer</span>
<button class="member-remove-btn" onclick="revokeInvite(this)" title="Revoke invite">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="2" y1="2" x2="14" y2="14"/><line x1="14" y1="2" x2="2" y2="14"/></svg>
</button>
</div>
</div>
</div>
<!-- Roles & permissions reference -->
<div class="section-card" style="margin-bottom: 20px;">
<div class="section-card-header">
<div class="section-card-title">Roles &amp; permissions</div>
</div>
<div class="section-card-body" style="padding: 4px 0 8px; gap: 0;">
<div class="role-def-row">
<span class="member-role-badge role-owner" style="min-width:80px;text-align:center;">Owner</span>
<div class="role-def-text">Full workspace control, billing access, can transfer or delete the workspace. Only one owner per workspace.</div>
</div>
<div class="role-def-row">
<span class="member-role-badge role-admin" style="min-width:80px;text-align:center;">Admin</span>
<div class="role-def-text">Manage members and roles, create and edit all operations and agents, manage API keys.</div>
</div>
<div class="role-def-row">
<span class="member-role-badge role-developer" style="min-width:80px;text-align:center;">Developer</span>
<div class="role-def-text">Create and edit operations and agents. Cannot manage members or workspace settings.</div>
</div>
<div class="role-def-row">
<span class="member-role-badge role-viewer" style="min-width:80px;text-align:center;">Viewer</span>
<div class="role-def-text">Read-only access to operations, agents, and logs. Cannot create or modify anything.</div>
</div>
</div>
</div>
</div>
<script>
function toggleInviteForm() {
var f = document.getElementById('invite-form');
var s = document.getElementById('invite-success');
if (f.style.display === 'none') {
f.style.display = '';
s.style.display = 'none';
document.getElementById('invite-email').focus();
} else {
f.style.display = 'none';
}
}
function sendInvite() {
var email = document.getElementById('invite-email').value.trim();
if (!email || !email.includes('@')) return;
document.getElementById('invite-success').style.display = '';
document.getElementById('invite-email').value = '';
}
function updateRole(select, email) {
// In production: API call to update role
console.log('Role updated:', email, '->', select.value);
}
function removeMember(btn, name) {
if (!confirm('Remove ' + name + ' from this workspace?')) return;
btn.closest('.member-row').remove();
updateMembersCount();
}
function revokeInvite(btn) {
btn.closest('.member-row').remove();
var pending = document.getElementById('pending-invites');
if (pending && !pending.querySelector('.member-row')) {
pending.style.display = 'none';
}
}
function updateMembersCount() {
var rows = document.querySelectorAll('#members-list .member-row');
var label = document.getElementById('members-count-label');
if (label) label.textContent = rows.length + ' member' + (rows.length !== 1 ? 's' : '');
}
</script>
<!-- ■ SECURITY ■ -->
<div id="section-security" class="section-anchor">
<div class="section-card" style="margin-bottom: 20px;">
@@ -464,8 +274,15 @@
<div style="padding: 4px 20px 16px;">
<div class="settings-section-divider" style="margin-top:0;"></div>
<div style="font-size:13.5px;font-weight:600;color:var(--text-primary);margin-bottom:12px;">Advanced security</div>
<div class="field-hint" style="margin-bottom:12px;">Two-factor authentication, passkeys and session management are not implemented yet. The current live security flow is password-based login with HttpOnly session cookies.</div>
<div style="font-size:13.5px;font-weight:600;color:var(--text-primary);margin-bottom:12px;">Planned security capabilities</div>
<div class="callout warning" style="margin-bottom:0;">
<svg class="callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--amber)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<polygon points="8,1.5 15.5,14.5 0.5,14.5" fill="none"/><path d="M8 6v4M8 11.5v.5"/>
</svg>
<div>
<strong>Not available in this build.</strong> Two-factor authentication, passkeys and multi-session inventory are planned, but the current live flow is password-based login with one HttpOnly browser session.
</div>
</div>
</div>
</div>
@@ -477,7 +294,7 @@
<div class="member-row">
<div class="member-info">
<div class="member-name">Browser session — <span style="color:var(--green);font-size:12px;">current</span></div>
<div class="member-email">Session expires according to server-side auth settings. Use Log out to revoke the current browser session.</div>
<div class="member-email" id="settings-session-summary">Loading current session details…</div>
</div>
</div>
</div>
@@ -491,73 +308,28 @@
<div class="section-card-title">Notification preferences</div>
</div>
<div style="padding: 8px 20px 16px;">
<div class="toggle-setting-row">
<div class="toggle-setting-text">
<div class="toggle-setting-label">Operation error spike</div>
<div class="toggle-setting-desc">Alert when error rate exceeds 5% over a 5-minute window.</div>
<div class="callout info" style="margin-bottom:14px;">
<svg class="callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--blue)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="6.5"/>
<path d="M8 11V8M8 5.5V5"/>
</svg>
<div>
<strong>Notifications are not wired yet.</strong> Alert routing and per-user preferences are planned, but this build does not persist or deliver notification settings.
</div>
<div class="toggle-sm on" onclick="this.classList.toggle('on')"></div>
</div>
<div class="toggle-setting-row">
<div class="toggle-setting-text">
<div class="toggle-setting-label">Upstream latency degradation</div>
<div class="toggle-setting-desc">Alert when p99 latency exceeds the baseline by 3×.</div>
<div class="planned-list">
<div class="planned-item">
<div class="planned-item-title">Operation error spike</div>
<div class="planned-item-desc">Alert when error rate exceeds a rolling threshold for a published tool.</div>
</div>
<div class="toggle-sm on" onclick="this.classList.toggle('on')"></div>
</div>
<div class="toggle-setting-row">
<div class="toggle-setting-text">
<div class="toggle-setting-label">Quota approaching limit</div>
<div class="toggle-setting-desc">Email when any operation reaches 80% of its monthly quota.</div>
<div class="planned-item">
<div class="planned-item-title">Upstream latency degradation</div>
<div class="planned-item-desc">Notify operators when p99 latency rises sharply above the baseline.</div>
</div>
<div class="toggle-sm on" onclick="this.classList.toggle('on')"></div>
</div>
<div class="toggle-setting-row">
<div class="toggle-setting-text">
<div class="toggle-setting-label">New team member joined</div>
<div class="toggle-setting-desc">Notify workspace admins when someone accepts an invite.</div>
<div class="planned-item">
<div class="planned-item-title">Quota and usage digests</div>
<div class="planned-item-desc">Deliver periodic summaries for quota consumption, errors and workspace activity.</div>
</div>
<div class="toggle-sm" onclick="this.classList.toggle('on')"></div>
</div>
<div class="toggle-setting-row">
<div class="toggle-setting-text">
<div class="toggle-setting-label">Weekly usage digest</div>
<div class="toggle-setting-desc">Email summary of invocations, errors and latency each Monday.</div>
</div>
<div class="toggle-sm on" onclick="this.classList.toggle('on')"></div>
</div>
<div class="field-hint" style="margin-top:12px;">Notification preferences are local UI placeholders for now and are not synced to the backend yet.</div>
</div>
</div>
</div>
<!-- ■ DANGER ZONE — moved to workspace-setup.html ■ -->
<div id="section-danger" style="display:none;">
<div class="section-card" style="border-color: rgba(248,81,73,0.25);">
<div class="section-card-header" style="border-bottom-color: rgba(248,81,73,0.15);">
<div class="section-card-title" style="color: var(--red);">Danger zone</div>
</div>
<div>
<div class="danger-zone-action">
<div class="danger-zone-text">
<div class="danger-zone-title">Rename workspace</div>
<div class="danger-zone-desc">Changes the workspace slug used in all API endpoints. Existing API keys will continue to work. All existing tool call references will break.</div>
</div>
<button class="btn-danger" id="settings-ws-rename-btn" type="button">Rename</button>
</div>
<div class="danger-zone-action">
<div class="danger-zone-text">
<div class="danger-zone-title">Export all data</div>
<div class="danger-zone-desc">Download a ZIP archive of all operation configs, schemas, mappings and invocation history as JSON. May take a few minutes.</div>
</div>
<button class="btn-danger" id="settings-ws-export-btn" type="button">Export</button>
</div>
<div class="danger-zone-action">
<div class="danger-zone-text">
<div class="danger-zone-title">Delete workspace</div>
<div class="danger-zone-desc">Permanently deletes all operations, keys, logs and settings. This action cannot be undone. You will be logged out immediately.</div>
</div>
<button class="btn-danger" id="settings-ws-delete-btn" type="button">Delete workspace</button>
</div>
</div>
</div>
+1
View File
@@ -13,6 +13,7 @@
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/api.js"></script>
<script src="js/ui-feedback.js"></script>
<script src="js/workspace.js"></script>
<script src="js/auth.js"></script>
<script>window.CrankAuth.guardProtectedPage();</script>
+11
View File
@@ -103,6 +103,17 @@ tls:
</div>
</div>
<div class="info-callout info-callout-subtle" style="margin-bottom: 20px;">
<svg class="info-callout-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="7"/>
<path d="M8 11V8M8 5v-.5"/>
</svg>
<div class="info-callout-body">
<div class="info-callout-title">Live actions save the draft first</div>
<div class="info-callout-text">Sample uploads, test runs, YAML actions, publish and gRPC descriptor discovery all persist the current draft before calling the backend.</div>
</div>
</div>
<div class="config-card" style="margin-bottom: 20px;">
<div class="config-card-header">
<div class="config-card-header-icon">
+4 -4
View File
@@ -319,12 +319,12 @@
</div>
<!-- Empty state -->
<div class="empty-state" x-show="!loading && totalFiltered === 0">
<h3 data-i18n="ops.empty.title">No operations found</h3>
<p data-i18n="ops.empty.sub">Try adjusting your search or filters.</p>
<div class="empty-state" x-show="!loading && !loadError && totalFiltered === 0">
<h3 x-text="operations.length === 0 ? 'No operations yet' : 'No operations found'">No operations found</h3>
<p x-text="operations.length === 0 ? 'Create the first operation in this workspace to expose a live MCP tool.' : 'Try adjusting your search or filters.'">Try adjusting your search or filters.</p>
</div>
<div class="empty-state" x-show="!loading && loadError">
<div class="empty-state" x-show="!loading && !!loadError">
<h3>Backend connection failed</h3>
<p x-text="loadError"></p>
</div>
+29 -1
View File
@@ -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) {
+29
View File
@@ -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 = '<svg width="13" height="13"><use href="' + (window.APP_BASE || '') + 'icons/general/check.svg#icon"/></svg>';
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() {
+7
View File
@@ -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');
+26 -7
View File
@@ -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 =
'<div class="empty-state-title">' + title + '</div>' +
'<div class="empty-state-text">' + message + '</div>';
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) {
+33 -64
View File
@@ -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();
});
+33 -7
View File
@@ -76,9 +76,19 @@ document.addEventListener('DOMContentLoaded', function () {
return 'Wk ' + (index + 1);
}
function renderEmpty(message) {
chartWrap.innerHTML = '<div style="padding:32px;color:var(--text-muted);text-align:center;width:100%;">' + message + '</div>';
tableBody.innerHTML = '<tr><td colspan="7" style="padding:24px;text-align:center;color:var(--text-muted);">' + message + '</td></tr>';
function renderEmpty(title, message) {
chartWrap.innerHTML =
'<div class="empty-state" style="width:100%;">' +
'<div class="empty-state-title">' + title + '</div>' +
'<div class="empty-state-text">' + message + '</div>' +
'</div>';
tableBody.innerHTML =
'<tr><td colspan="7" style="padding:24px;">' +
'<div class="empty-state" style="padding:0;">' +
'<div class="empty-state-title">' + title + '</div>' +
'<div class="empty-state-text">' + message + '</div>' +
'</div>' +
'</td></tr>';
}
function renderStats() {
@@ -119,7 +129,11 @@ document.addEventListener('DOMContentLoaded', function () {
chartWrap.innerHTML = '';
if (!timeline.length) {
chartWrap.innerHTML = '<div style="padding:32px;color:var(--text-muted);text-align:center;width:100%;">No usage data for selected period</div>';
chartWrap.innerHTML =
'<div class="empty-state" style="width:100%;">' +
'<div class="empty-state-title">No usage data yet</div>' +
'<div class="empty-state-text">Invocation metrics will appear here after tests or published tool calls in the selected period.</div>' +
'</div>';
return;
}
@@ -145,7 +159,13 @@ document.addEventListener('DOMContentLoaded', function () {
tableBody.innerHTML = '';
if (!operations.length) {
tableBody.innerHTML = '<tr><td colspan="7" style="padding:24px;text-align:center;color:var(--text-muted);">No operation usage data for selected period</td></tr>';
tableBody.innerHTML =
'<tr><td colspan="7" style="padding:24px;">' +
'<div class="empty-state" style="padding:0;">' +
'<div class="empty-state-title">No operation breakdown yet</div>' +
'<div class="empty-state-text">The selected period has no invocation samples to break down by operation.</div>' +
'</div>' +
'</td></tr>';
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) {
+52 -9
View File
@@ -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() {