From e4d2f6adc934d396617dc4c168bb5c8a8f4e63e5 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 30 Mar 2026 01:49:32 +0300 Subject: [PATCH 1/3] feat: connect alpine logs and usage to admin api --- TASKS.md | 11 +- apps/ui/html/logs.html | 1 + apps/ui/html/usage.html | 3 +- apps/ui/js/api.js | 32 ++ apps/ui/js/logs.js | 705 ++++++++++++----------------- apps/ui/js/usage.js | 496 ++++++++++---------- docs/alpine-ui-integration-plan.md | 21 +- 7 files changed, 609 insertions(+), 660 deletions(-) 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 +
Used in API endpoints and across the console. Only lowercase letters, numbers and hyphens.
- +
- +
- @@ -162,10 +169,10 @@
- + + +
@@ -188,7 +195,7 @@
- +
diff --git a/apps/ui/html/workspace-setup.html b/apps/ui/html/workspace-setup.html index 6aab8a5..14ca06d 100644 --- a/apps/ui/html/workspace-setup.html +++ b/apps/ui/html/workspace-setup.html @@ -14,6 +14,7 @@ +
@@ -304,14 +305,14 @@
Export all data
Download a ZIP archive of all operation configs, schemas, mappings and invocation history as JSON.
- +
Delete workspace
Permanently deletes all operations, keys, logs and settings. This action cannot be undone. You will be logged out immediately.
- +
diff --git a/apps/ui/js/api.js b/apps/ui/js/api.js index e5f3d58..f8dd2be 100644 --- a/apps/ui/js/api.js +++ b/apps/ui/js/api.js @@ -92,6 +92,27 @@ listWorkspaces: function() { return get('/workspaces'); }, + createWorkspace: function(payload) { + return post('/workspaces', payload); + }, + getWorkspace: function(workspaceId) { + return get('/workspaces/' + encodeURIComponent(workspaceId)); + }, + updateWorkspace: function(workspaceId, payload) { + return patch('/workspaces/' + encodeURIComponent(workspaceId), payload); + }, + listMemberships: function(workspaceId) { + return get('/workspaces/' + encodeURIComponent(workspaceId) + '/members'); + }, + listInvitations: function(workspaceId) { + return get('/workspaces/' + encodeURIComponent(workspaceId) + '/invitations'); + }, + createInvitation: function(workspaceId, payload) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/invitations', payload); + }, + deleteInvitation: function(workspaceId, invitationId) { + return del('/workspaces/' + encodeURIComponent(workspaceId) + '/invitations/' + encodeURIComponent(invitationId)); + }, listOperations: function(workspaceId) { return get('/workspaces/' + encodeURIComponent(workspaceId) + '/operations'); }, diff --git a/apps/ui/js/settings.js b/apps/ui/js/settings.js index 885c46a..d97418b 100644 --- a/apps/ui/js/settings.js +++ b/apps/ui/js/settings.js @@ -1,25 +1,39 @@ -// Populate profile from stored user -try { - var u = JSON.parse(localStorage.getItem('crank_user')); - if (u) { - document.getElementById('profile-avatar').textContent = u.initials || 'AT'; - document.getElementById('profile-display-name').textContent = u.name || 'Operator'; - document.getElementById('profile-email').textContent = u.email || ''; - var fi = document.getElementById('field-firstname'); - var fe = document.getElementById('field-email'); - if (fi && u.name) fi.value = u.name; - if (fe && u.email) fe.value = u.email; - } -} catch (e) {} +function populateProfile() { + try { + var user = JSON.parse(localStorage.getItem('crank_user')); + if (!user) { + return; + } -// Inject language switcher into section-profile card body, before Save button -(function() { + document.getElementById('profile-avatar').textContent = user.initials || 'AT'; + document.getElementById('profile-display-name').textContent = user.name || 'Operator'; + document.getElementById('profile-email').textContent = user.email || ''; + + var firstName = document.getElementById('field-firstname'); + var email = document.getElementById('field-email'); + if (firstName && user.name) { + firstName.value = user.name; + } + if (email && user.email) { + email.value = user.email; + } + } catch (_error) { + } +} + +function injectLanguageSwitcher() { var profileSection = document.getElementById('section-profile'); - if (!profileSection) return; + if (!profileSection) { + return; + } + var cardBody = profileSection.querySelector('.section-card-body'); - if (!cardBody) return; + if (!cardBody) { + return; + } + fetch('html/fragments/lang-switcher.html') - .then(function(r) { return r.text(); }) + .then(function(response) { return response.text(); }) .then(function(html) { var saveRow = cardBody.querySelector('div[style*="justify-content:flex-end"]'); if (saveRow) { @@ -27,47 +41,128 @@ try { } else { cardBody.insertAdjacentHTML('beforeend', html); } - if (typeof applyLang === 'function') applyLang(); + if (typeof applyLang === 'function') { + applyLang(); + } }); -})(); +} -// Settings sidebar navigation -var navItems = document.querySelectorAll('.settings-nav-item[data-section]'); -navItems.forEach(function(btn) { - btn.addEventListener('click', function() { - navItems.forEach(function(b) { b.classList.remove('active'); }); - this.classList.add('active'); - var target = document.getElementById('section-' + this.dataset.section); - if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); +function bindSectionNavigation() { + var navItems = document.querySelectorAll('.settings-nav-item[data-section]'); + navItems.forEach(function (button) { + button.addEventListener('click', function () { + navItems.forEach(function (item) { item.classList.remove('active'); }); + this.classList.add('active'); + var target = document.getElementById('section-' + this.dataset.section); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); }); -}); -// Hash navigation (e.g. settings.html#security) -if (window.location.hash) { - var hash = window.location.hash.slice(1); - var btn = document.querySelector('.settings-nav-item[data-section="' + hash + '"]'); - if (btn) setTimeout(function() { btn.click(); }, 100); -} -// Default: scroll to profile -else { - var profileBtn = document.querySelector('.settings-nav-item[data-section="profile"]'); - if (profileBtn) profileBtn.classList.add('active'); -} + var initialSection = window.location.hash ? window.location.hash.slice(1) : 'profile'; + var initialButton = document.querySelector('.settings-nav-item[data-section="' + initialSection + '"]') + || document.querySelector('.settings-nav-item[data-section="profile"]'); + if (initialButton) { + setTimeout(function () { initialButton.click(); }, 50); + } -// IntersectionObserver scroll-spy: highlight active sidebar item as user scrolls -var observer = new IntersectionObserver(function(entries) { - entries.forEach(function(entry) { - if (entry.isIntersecting) { - var sectionId = entry.target.id; // e.g. "section-profile" - var name = sectionId.replace('section-', ''); // e.g. "profile" - document.querySelectorAll('.settings-nav-item[data-section]').forEach(function(b) { - b.classList.toggle('active', b.dataset.section === name); + var observer = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (!entry.isIntersecting) { + return; + } + var section = entry.target.id.replace('section-', ''); + document.querySelectorAll('.settings-nav-item[data-section]').forEach(function (button) { + button.classList.toggle('active', button.dataset.section === section); }); + }); + }, { threshold: 0.3 }); + + document.querySelectorAll('[id^="section-"]').forEach(function (section) { + if (section.style.display !== 'none') { + observer.observe(section); } }); -}, { threshold: 0.3 }); +} -// Observe all visible sections (not hidden ones) -document.querySelectorAll('[id^="section-"]').forEach(function(el) { - if (el.style.display !== 'none') observer.observe(el); +async function loadWorkspaceSettings() { + if (!window.CrankApi || !window.whenWorkspacesReady) { + return; + } + + await window.whenWorkspacesReady(); + var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; + if (!workspace) { + return; + } + + try { + var record = await window.CrankApi.getWorkspace(workspace.id); + var item = record.workspace; + var settings = item.settings || {}; + + document.getElementById('settings-ws-slug').value = item.slug || ''; + document.getElementById('settings-ws-display-name').value = item.display_name || ''; + document.getElementById('settings-ws-description').value = settings.description || ''; + document.getElementById('settings-ws-timezone').value = settings.timezone || 'UTC'; + document.getElementById('settings-ws-protocol').value = settings.default_protocol || 'rest'; + + document.querySelectorAll('#section-workspace .lang-btn').forEach(function (button) { + button.classList.toggle('active', button.dataset.lang === (settings.language || (localStorage.getItem('crank_lang') || 'en'))); + }); + + var saveButton = document.getElementById('settings-ws-save-btn'); + if (saveButton.dataset.bound === 'true') { + return; + } + saveButton.dataset.bound = 'true'; + + saveButton.addEventListener('click', async function () { + var saveButton = this; + var original = saveButton.textContent; + saveButton.disabled = true; + saveButton.textContent = 'Saving…'; + + try { + await window.CrankApi.updateWorkspace(item.id, { + slug: document.getElementById('settings-ws-slug').value.trim(), + display_name: document.getElementById('settings-ws-display-name').value.trim(), + settings: { + description: document.getElementById('settings-ws-description').value.trim(), + timezone: document.getElementById('settings-ws-timezone').value, + default_protocol: document.getElementById('settings-ws-protocol').value, + language: localStorage.getItem('crank_lang') || 'en', + color: (workspace.settings && workspace.settings.color) || '#0d9488', + }, + }); + + if (window.refreshWorkspaces) { + var workspaces = await window.refreshWorkspaces(); + var updated = workspaces.find(function (entry) { return entry.id === item.id; }); + if (updated && window.setCurrentWorkspace) { + window.setCurrentWorkspace(updated); + } + } + + saveButton.textContent = 'Saved ✓'; + } catch (error) { + alert(error.message || 'Failed to save workspace settings'); + saveButton.textContent = original; + } finally { + setTimeout(function () { + saveButton.disabled = false; + saveButton.textContent = original; + }, 1200); + } + }); + } catch (_error) { + } +} + +document.addEventListener('DOMContentLoaded', function () { + populateProfile(); + injectLanguageSwitcher(); + bindSectionNavigation(); + loadWorkspaceSettings(); }); diff --git a/apps/ui/js/workspace-setup.js b/apps/ui/js/workspace-setup.js index 6d410db..81156c7 100644 --- a/apps/ui/js/workspace-setup.js +++ b/apps/ui/js/workspace-setup.js @@ -1,193 +1,459 @@ -var selectedColor = '#0d9488'; -var slugManual = false; -var isCreateMode = false; -var formDirty = false; +var workspaceFormState = { + selectedColor: '#0d9488', + slugManual: false, + isCreateMode: false, + formDirty: false, + workspaceId: null, + workspaceRecord: null, + memberships: [], + invitations: [], +}; -function initPage() { +function normalizeSlug(value) { + return value + .toLowerCase() + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +function uiRoleToApi(value) { + if (value === 'developer') { + return 'operator'; + } + return value; +} + +function roleLabel(value) { + if (value === 'operator') { + return 'Developer'; + } + return value.charAt(0).toUpperCase() + value.slice(1); +} + +function roleClass(value) { + if (value === 'operator') { + return 'role-developer'; + } + return 'role-' + value; +} + +function currentLanguage() { + return localStorage.getItem('crank_lang') || 'en'; +} + +function setFormDirty(value) { + workspaceFormState.formDirty = value; +} + +function formElements() { + return { + name: document.getElementById('ws-name'), + slug: document.getElementById('ws-slug'), + desc: document.getElementById('ws-desc'), + protocol: document.getElementById('ws-protocol'), + timezone: document.getElementById('ws-timezone'), + submit: document.getElementById('submit-btn'), + avatar: document.getElementById('ws-avatar-preview'), + }; +} + +function workspacePayload() { + var elements = formElements(); + return { + slug: elements.slug.value.trim(), + display_name: elements.name.value.trim(), + settings: { + description: elements.desc.value.trim(), + default_protocol: elements.protocol.value, + timezone: elements.timezone.value, + language: currentLanguage(), + color: workspaceFormState.selectedColor, + }, + }; +} + +function applyWorkspaceRecord(record) { + var workspace = record.workspace; + var settings = workspace.settings || {}; + var elements = formElements(); + + workspaceFormState.workspaceId = workspace.id; + workspaceFormState.workspaceRecord = record; + workspaceFormState.selectedColor = settings.color || '#0d9488'; + workspaceFormState.slugManual = true; + + elements.name.value = workspace.display_name || ''; + elements.slug.value = workspace.slug || ''; + elements.desc.value = settings.description || ''; + elements.protocol.value = settings.default_protocol || 'rest'; + elements.timezone.value = settings.timezone || 'UTC'; + elements.avatar.textContent = (workspace.display_name || workspace.slug || '?').charAt(0).toUpperCase(); + elements.avatar.style.background = workspaceFormState.selectedColor; + + document.querySelectorAll('.ws-color-swatch').forEach(function (swatch) { + swatch.classList.toggle('active', swatch.dataset.color === workspaceFormState.selectedColor); + }); + + setFormDirty(false); +} + +function addMemberRow(container, membership) { + var row = document.createElement('div'); + row.className = 'member-row'; + row.innerHTML = + '
' + + membership.user.display_name.slice(0, 2).toUpperCase() + + '
' + + '
' + + '
' + membership.user.display_name + '
' + + '
' + membership.user.email + '
' + + '
' + + '
' + + '' + roleLabel(membership.role) + ''; + container.appendChild(row); +} + +function renderMembers() { + var container = document.getElementById('members-list'); + var label = document.getElementById('members-count-label'); + if (!container || !label) { + return; + } + + container.innerHTML = ''; + workspaceFormState.memberships.forEach(function (membership) { + addMemberRow(container, membership); + }); + label.textContent = workspaceFormState.memberships.length + ' member' + (workspaceFormState.memberships.length === 1 ? '' : 's'); +} + +function renderInvitations() { + var pending = document.getElementById('pending-invites'); + var count = document.getElementById('pending-count'); + if (!pending || !count) { + return; + } + + var items = workspaceFormState.invitations.filter(function (record) { + return record.invitation.status === 'pending'; + }); + + count.textContent = String(items.length); + if (!items.length) { + pending.style.display = 'none'; + return; + } + + pending.style.display = ''; + pending.innerHTML = + '
' + + 'Pending invites ' + items.length + '' + + '
'; + + items.forEach(function (record) { + var invitation = record.invitation; + var row = document.createElement('div'); + row.className = 'member-row'; + row.innerHTML = + '
?
' + + '
' + + '
' + invitation.email + '
' + + '
Invited ' + invitation.created_at.slice(0, 10) + ' · pending
' + + '
' + + '
' + + '' + roleLabel(invitation.role) + '' + + ''; + pending.appendChild(row); + }); +} + +async function loadWorkspaceAccessData() { + if (workspaceFormState.isCreateMode || !window.CrankApi || !workspaceFormState.workspaceId) { + return; + } + + var membershipsResponse = await window.CrankApi.listMemberships(workspaceFormState.workspaceId); + var invitationsResponse = await window.CrankApi.listInvitations(workspaceFormState.workspaceId); + workspaceFormState.memberships = membershipsResponse && membershipsResponse.items ? membershipsResponse.items : []; + workspaceFormState.invitations = invitationsResponse && invitationsResponse.items ? invitationsResponse.items : []; + renderMembers(); + renderInvitations(); +} + +function updatePageMode() { var params = new URLSearchParams(window.location.search); - isCreateMode = params.get('mode') === 'create'; + workspaceFormState.isCreateMode = params.get('mode') === 'create'; - if (isCreateMode) { + var submit = document.getElementById('submit-btn'); + if (workspaceFormState.isCreateMode) { document.title = 'Crank — Create workspace'; document.getElementById('page-title').textContent = 'Create a new workspace'; document.getElementById('page-subtitle').textContent = 'A workspace is a shared environment for your team\'s MCP operations and agents. Each workspace gets its own MCP endpoint namespace.'; document.getElementById('section-invite').style.display = ''; document.getElementById('footer-note').style.display = ''; - var btn = document.getElementById('submit-btn'); - btn.textContent = 'Create workspace'; - btn.disabled = true; - btn.style.opacity = '0.5'; - btn.style.cursor = 'not-allowed'; + submit.textContent = 'Create workspace'; + submit.disabled = true; + submit.style.opacity = '0.5'; + submit.style.cursor = 'not-allowed'; } else { - // Edit mode — prefill from current workspace document.getElementById('section-members').style.display = ''; document.getElementById('section-danger').style.display = ''; - var ws = getCurrentWs(); - document.getElementById('ws-name').value = ws.name; - document.getElementById('ws-slug').value = ws.slug; - document.getElementById('ws-avatar-preview').textContent = ws.letter; - document.getElementById('ws-avatar-preview').style.background = ws.color; - selectedColor = ws.color; - slugManual = true; - // Activate matching color swatch - document.querySelectorAll('.ws-color-swatch').forEach(function(s) { - s.classList.toggle('active', s.dataset.color === ws.color); - }); - // Scroll to #section-members if hash present - if (window.location.hash === '#section-members') { - setTimeout(function() { - document.getElementById('section-members').scrollIntoView({ behavior: 'smooth', block: 'start' }); - }, 100); - } + submit.textContent = 'Save changes'; } +} - // Apply language state to lang buttons - var lang = localStorage.getItem('crank_lang') || 'en'; - document.querySelectorAll('.lang-btn').forEach(function(btn) { - btn.classList.toggle('active', btn.dataset.lang === lang); +function onWsNameInput(value) { + var elements = formElements(); + var normalized = value.trim(); + elements.avatar.textContent = normalized ? normalized.charAt(0).toUpperCase() : '?'; + if (!workspaceFormState.slugManual) { + elements.slug.value = normalizeSlug(value); + } + if (workspaceFormState.isCreateMode) { + validateCreateForm(); + } + setFormDirty(true); +} + +function onWsSlugInput(value) { + formElements().slug.value = normalizeSlug(value); + workspaceFormState.slugManual = true; + if (workspaceFormState.isCreateMode) { + validateCreateForm(); + } + setFormDirty(true); +} + +function pickColor(element) { + workspaceFormState.selectedColor = element.dataset.color; + document.querySelectorAll('.ws-color-swatch').forEach(function (swatch) { + swatch.classList.remove('active'); }); -} - -function onWsNameInput(val) { - var letter = val.trim() ? val.trim()[0].toUpperCase() : '?'; - document.getElementById('ws-avatar-preview').textContent = letter; - if (!slugManual) { - var slug = val.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, ''); - document.getElementById('ws-slug').value = slug; - } - if (isCreateMode) validateCreateForm(); - formDirty = true; -} - -function onWsSlugInput(val) { - var clean = val.toLowerCase().replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-'); - document.getElementById('ws-slug').value = clean; - slugManual = true; - if (isCreateMode) validateCreateForm(); - formDirty = true; -} - -function pickColor(el) { - selectedColor = el.dataset.color; - document.querySelectorAll('.ws-color-swatch').forEach(function(s) { s.classList.remove('active'); }); - el.classList.add('active'); - document.getElementById('ws-avatar-preview').style.background = selectedColor; - formDirty = true; + element.classList.add('active'); + formElements().avatar.style.background = workspaceFormState.selectedColor; + setFormDirty(true); } function validateCreateForm() { - var name = document.getElementById('ws-name').value.trim(); - var slug = document.getElementById('ws-slug').value.trim(); - var btn = document.getElementById('submit-btn'); - var valid = name.length > 0 && slug.length > 0; - btn.disabled = !valid; - btn.style.opacity = valid ? '1' : '0.5'; - btn.style.cursor = valid ? 'pointer' : 'not-allowed'; + var elements = formElements(); + var valid = Boolean(elements.name.value.trim() && elements.slug.value.trim()); + elements.submit.disabled = !valid; + elements.submit.style.opacity = valid ? '1' : '0.5'; + elements.submit.style.cursor = valid ? 'pointer' : 'not-allowed'; } -function submitForm() { - var name = document.getElementById('ws-name').value.trim(); - var slug = document.getElementById('ws-slug').value.trim(); - if (!name || !slug) return; +function inviteRows() { + return Array.from(document.querySelectorAll('#invite-rows .invite-row')).map(function (row) { + return { + email: row.querySelector('input').value.trim(), + role: uiRoleToApi(row.querySelector('select').value), + }; + }).filter(function (row) { + return row.email; + }); +} - formDirty = false; +async function submitForm() { + var payload = workspacePayload(); + if (!payload.display_name || !payload.slug || !window.CrankApi) { + return; + } - if (isCreateMode) { - if (window.WS_LIST) { - WS_LIST.push({ slug: slug, name: slug, role: 'Owner', letter: name[0].toUpperCase(), color: selectedColor }); + var submit = document.getElementById('submit-btn'); + var originalLabel = submit.textContent; + submit.disabled = true; + submit.textContent = workspaceFormState.isCreateMode ? 'Creating…' : 'Saving…'; + + try { + var response; + if (workspaceFormState.isCreateMode) { + response = await window.CrankApi.createWorkspace(payload); + } else { + response = await window.CrankApi.updateWorkspace(workspaceFormState.workspaceId, payload); } - localStorage.setItem('crank_workspace', slug); - window.location.href = (window.APP_BASE||'')+'index.html'; - } else { - // Update current workspace in WS_LIST - if (window.WS_LIST) { - var current = localStorage.getItem('crank_workspace') || 'acme-workspace'; - var idx = WS_LIST.findIndex(function(w) { return w.slug === current; }); - if (idx !== -1) { - WS_LIST[idx].name = slug; - WS_LIST[idx].slug = slug; - WS_LIST[idx].letter = name[0].toUpperCase(); - WS_LIST[idx].color = selectedColor; + + var record = response && response.workspace ? response : { workspace: response.workspace || response }; + var workspace = record.workspace; + + if (workspaceFormState.isCreateMode) { + var invites = inviteRows(); + for (var index = 0; index < invites.length; index += 1) { + await window.CrankApi.createInvitation(workspace.id, { + email: invites[index].email, + role: invites[index].role, + }); } } - localStorage.setItem('crank_workspace', slug); - // Show saved feedback - var btn = document.getElementById('submit-btn'); - var orig = btn.textContent; - btn.textContent = 'Saved ✓'; - btn.disabled = true; - setTimeout(function() { btn.textContent = orig; btn.disabled = false; }, 1800); + + var workspaces = await window.refreshWorkspaces(); + var mapped = workspaces.find(function (item) { return item.id === workspace.id; }) || { + id: workspace.id, + slug: workspace.slug, + name: workspace.display_name, + role: 'Owner', + letter: (workspace.display_name || workspace.slug).charAt(0).toUpperCase(), + color: payload.settings.color || '#0d9488', + status: workspace.status, + settings: payload.settings, + }; + + window.setCurrentWorkspace(mapped); + workspaceFormState.workspaceId = workspace.id; + workspaceFormState.workspaceRecord = { workspace: workspace }; + setFormDirty(false); + + if (workspaceFormState.isCreateMode) { + window.location.href = (window.APP_BASE || '') + 'html/workspace-setup.html'; + return; + } + + applyWorkspaceRecord({ workspace: workspace }); + await loadWorkspaceAccessData(); + submit.textContent = 'Saved ✓'; + setTimeout(function () { + submit.textContent = originalLabel; + submit.disabled = false; + }, 1400); + } catch (error) { + alert(error.message || 'Failed to save workspace'); + submit.disabled = false; + submit.textContent = originalLabel; } } -// Create mode invite rows function addInviteRow() { var rows = document.getElementById('invite-rows'); - var div = document.createElement('div'); - div.className = 'invite-row'; - div.innerHTML = + var node = document.createElement('div'); + node.className = 'invite-row'; + node.innerHTML = '' + '' + - ''; - rows.appendChild(div); + rows.appendChild(node); } -function removeInviteRow(btn) { - btn.closest('.invite-row').remove(); +function removeInviteRow(button) { + button.closest('.invite-row').remove(); } -// Members management (edit mode) 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'; + var form = document.getElementById('invite-form'); + var success = document.getElementById('invite-success'); + if (!form) { + return; + } + + if (form.style.display === 'none') { + form.style.display = ''; + if (success) { + success.style.display = 'none'; + } document.getElementById('invite-email').focus(); } else { - f.style.display = 'none'; + form.style.display = 'none'; } } -function sendInvite() { +async function sendInvite() { + if (!window.CrankApi || !workspaceFormState.workspaceId) { + return; + } + var email = document.getElementById('invite-email').value.trim(); - if (!email || !email.includes('@')) return; - document.getElementById('invite-success').style.display = ''; - document.getElementById('invite-email').value = ''; + var role = uiRoleToApi(document.getElementById('invite-role').value); + if (!email || email.indexOf('@') === -1) { + return; + } + + try { + await window.CrankApi.createInvitation(workspaceFormState.workspaceId, { email: email, role: role }); + document.getElementById('invite-email').value = ''; + document.getElementById('invite-success').style.display = ''; + await loadWorkspaceAccessData(); + } catch (error) { + alert(error.message || 'Failed to send invite'); + } } -function updateRole(select, email) { - console.log('Role updated:', email, '->', select.value); +function updateRole() { + alert('Role management is not available yet in the backend.'); } -function removeMember(btn, name) { - if (!confirm('Remove ' + name + ' from this workspace?')) return; - btn.closest('.member-row').remove(); - 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' : ''); +function removeMember() { + alert('Member removal is not available yet in the backend.'); } -function revokeInvite(btn) { - btn.closest('.member-row').remove(); - var pending = document.getElementById('pending-invites'); - if (pending && !pending.querySelector('.member-row')) pending.style.display = 'none'; +async function revokeInviteById(invitationId) { + if (!window.CrankApi || !workspaceFormState.workspaceId) { + return; + } + + try { + await window.CrankApi.deleteInvitation(workspaceFormState.workspaceId, invitationId); + await loadWorkspaceAccessData(); + } catch (error) { + alert(error.message || 'Failed to revoke invite'); + } } -document.addEventListener('DOMContentLoaded', function() { - initPage(); - if (isCreateMode) document.getElementById('ws-name').focus(); -}); +function revokeInvite(button) { + var row = button.closest('.member-row'); + if (!row) { + return; + } + row.remove(); +} -window.addEventListener('beforeunload', function(e) { - if (formDirty) { - e.preventDefault(); - e.returnValue = ''; +async function initPage() { + updatePageMode(); + + var language = currentLanguage(); + document.querySelectorAll('.lang-btn').forEach(function (button) { + button.classList.toggle('active', button.dataset.lang === language); + }); + + document.getElementById('export-workspace-btn').addEventListener('click', function () { + alert('Workspace export is not available yet in the backend.'); + }); + + document.getElementById('delete-workspace-btn').addEventListener('click', function () { + alert('Workspace deletion is not available yet in the backend.'); + }); + + if (workspaceFormState.isCreateMode) { + document.getElementById('ws-name').focus(); + return; + } + + await window.whenWorkspacesReady(); + var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; + if (!workspace || !window.CrankApi) { + return; + } + + try { + var record = await window.CrankApi.getWorkspace(workspace.id); + applyWorkspaceRecord(record); + await loadWorkspaceAccessData(); + } catch (error) { + alert(error.message || 'Failed to load workspace'); + } +} + +document.addEventListener('DOMContentLoaded', initPage); + +window.addEventListener('beforeunload', function (event) { + if (workspaceFormState.formDirty) { + event.preventDefault(); + event.returnValue = ''; } }); diff --git a/apps/ui/js/workspace.js b/apps/ui/js/workspace.js index f66e3aa..e2a3998 100644 --- a/apps/ui/js/workspace.js +++ b/apps/ui/js/workspace.js @@ -12,14 +12,16 @@ function workspaceColor(index) { function mapWorkspace(record, index) { var workspace = record && record.workspace ? record.workspace : record; var displayName = workspace.display_name || workspace.slug || workspace.id; + var settings = workspace.settings || {}; return { id: workspace.id, slug: workspace.slug, name: displayName, role: 'Owner', letter: displayName.charAt(0).toUpperCase(), - color: workspaceColor(index), + color: settings.color || workspaceColor(index), status: workspace.status, + settings: settings, }; } @@ -113,6 +115,11 @@ async function loadWorkspaces() { return workspaceLoadPromise; } +async function refreshWorkspaces() { + workspaceLoadPromise = null; + return loadWorkspaces(); +} + function initWorkspaceSwitcher() { renderWorkspaceList(); loadWorkspaces(); @@ -134,8 +141,16 @@ function switchWorkspace(workspaceId) { window.dispatchEvent(new CustomEvent('crank:workspacechange', { detail: workspace })); } +function setCurrentWorkspace(workspace) { + persistCurrentWorkspace(workspace); + renderWorkspaceList(); + window.dispatchEvent(new CustomEvent('crank:workspacechange', { detail: workspace })); +} + window.getCurrentWorkspace = getCurrentWs; window.whenWorkspacesReady = loadWorkspaces; +window.refreshWorkspaces = refreshWorkspaces; +window.setCurrentWorkspace = setCurrentWorkspace; window.hasWorkspaceApiFailure = function() { return workspaceLoadFailed; }; document.addEventListener('DOMContentLoaded', initWorkspaceSwitcher); diff --git a/docs/alpine-ui-integration-plan.md b/docs/alpine-ui-integration-plan.md index 4f928fe..514dde5 100644 --- a/docs/alpine-ui-integration-plan.md +++ b/docs/alpine-ui-integration-plan.md @@ -307,21 +307,21 @@ UI-файлы: Что еще не хватает: -- `switch current workspace` как backend-aware UI state, а не `localStorage`; -- нормальная серверная модель current user + memberships; -- endpoint на удаление участника или изменение роли, если UI хочет это поддерживать +- `switch current workspace` все еще живет на клиенте, а не в session/backend; +- endpoint на удаление участника или изменение роли, если UI хочет это поддерживать; +- delete/export workspace lifecycle пока не реализован на backend Отдельный конфликт: -- UI создает workspace локально и сразу считает его текущим; -- backend уже умеет создавать workspace, но у нас нет полноценной user-session модели; -- значит выбор текущего workspace пока придется держать на клиенте, но список и metadata брать с сервера. +- current workspace по-прежнему client-side; +- memberships и invitations уже live, но role-management пока read-only; +- `settings` page не должна дублировать этот flow, пока у нее нет своего backend-контракта Простой итог: -- create/edit workspace уже можно подключать; -- members/invites тоже частично готовы; -- но полноценный switch и role management еще не закрыты. +- `workspace-setup` уже подключен к live backend; +- create/edit workspace, refresh списка workspace и invitations работают; +- role management и workspace deletion остаются отдельным следующим этапом. ### 4.8. Settings @@ -339,7 +339,7 @@ UI-файлы: Что уже можно подключить: -- частично `GET /api/admin/workspaces/{workspace_id}` +- workspace block через `GET /api/admin/workspaces/{workspace_id}` - `PATCH /api/admin/workspaces/{workspace_id}` Что еще не хватает: @@ -359,8 +359,9 @@ UI-файлы: Простой итог: -- эту страницу нельзя подключать целиком сразу; -- сначала надо отделить workspace settings от пользовательского профиля. +- workspace block уже можно держать live; +- profile/security остаются временно mock/read-only; +- страницу целиком считать интегрированной пока нельзя. ### 4.9. Login