From 298bf791b997955310a9e1bb167555dec6fbaf8d Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 27 Mar 2026 14:04:46 +0000 Subject: [PATCH] feat(portal): add explicit workspace management panel --- .../v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md | 3 + .../cloudcp/portal/dist/build_manifest.json | 2 +- internal/cloudcp/portal/dist/portal_app.css | 91 +++++++---- internal/cloudcp/portal/dist/portal_app.js | 150 ++++++++++++------ .../frontend/src/account_controller.test.ts | 19 ++- .../portal/frontend/src/account_controller.ts | 11 +- .../frontend/src/account_runtime.test.ts | 52 ++++-- .../portal/frontend/src/account_runtime.ts | 58 +++---- .../portal/frontend/src/account_view.test.ts | 81 ++++++++-- .../portal/frontend/src/account_view.ts | 91 +++++++++-- .../portal/frontend/src/shell_view.test.ts | 5 +- .../cloudcp/portal/frontend/src/shell_view.ts | 77 +++++---- internal/cloudcp/portal/frontend/src/state.ts | 3 +- .../cloudcp/portal/frontend/src/styles.css | 19 ++- internal/cloudcp/portal/frontend/src/types.ts | 3 +- 15 files changed, 465 insertions(+), 200 deletions(-) diff --git a/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md b/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md index 7ac1328a5b..0c8b510440 100644 --- a/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md +++ b/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md @@ -140,6 +140,9 @@ Core rules: starts drilling into one workspace at a time. 9. Workspace fleets must summarize health and attention state at the account level, not force the user to scan badges one card at a time. +10. Workspace lifecycle actions must open in an explicit management surface + with the selected workspace context visible, not inside a hidden overflow + menu or a blind confirm-first interaction. ## Screen Model diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index 9998575396..2e601fff06 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "f091b6dbbdf775a750bbba5c4047fbba2553fa0393420916e2e9e5ca315e1437", + "source_hash": "9e66ef00fa2623eb7e00f30d4cd4a823bfb4552eeb89136392577ad25b7caad2", "build_inputs": [ "package.json", "tsconfig.json", diff --git a/internal/cloudcp/portal/dist/portal_app.css b/internal/cloudcp/portal/dist/portal_app.css index 9e628e2ca8..b12e37421c 100644 --- a/internal/cloudcp/portal/dist/portal_app.css +++ b/internal/cloudcp/portal/dist/portal_app.css @@ -267,43 +267,73 @@ header .logout-btn:hover { gap: 8px; flex-shrink: 0; } -.workspace-menu-wrap { - position: relative; +.btn-workspace-manage { + min-width: 112px; } -.btn-workspace-menu { - min-width: 96px; -} -.workspace-menu { - position: absolute; - top: calc(100% + 6px); - right: 0; - z-index: 10; - min-width: 190px; +.workspace-management-panel { + margin-top: 14px; background: #fff; border: 1px solid #dbe4f0; - border-radius: 10px; - padding: 6px; - box-shadow: 0 12px 28px rgba(15, 23, 42, .12); + border-radius: 12px; + padding: 18px; + display: flex; + flex-direction: column; + gap: 14px; } -.workspace-menu-item { - width: 100%; - background: transparent; - border: 0; - border-radius: 8px; - padding: 9px 12px; - font: inherit; - text-align: left; - cursor: pointer; - color: #0f172a; +.workspace-management-panel.visible { + border-color: #bfdbfe; + box-shadow: 0 10px 24px rgba(29, 78, 216, .08); } -.workspace-menu-item:hover { +.workspace-management-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} +.workspace-management-header h3 { + margin: 0 0 4px; + font-size: 16px; + font-weight: 700; +} +.workspace-management-header p { + margin: 0; + font-size: 13px; + color: #64748b; + line-height: 1.5; + max-width: 560px; +} +.workspace-management-empty { + font-size: 14px; + color: #64748b; background: #f8fafc; + border: 1px dashed #cbd5e1; + border-radius: 10px; + padding: 16px; } -.workspace-menu-item-danger { - color: #b91c1c; +.workspace-management-content h4 { + margin: 0 0 8px; + font-size: 18px; + font-weight: 700; } -.workspace-menu-item-danger:hover { - background: #fef2f2; +.workspace-management-meta { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #64748b; + margin-bottom: 8px; +} +.workspace-management-summary { + margin: 0; + font-size: 14px; + color: #475569; + line-height: 1.55; +} +.workspace-management-actions { + margin-top: 14px; + display: flex; + gap: 10px; + flex-wrap: wrap; } .btn-primary { background: #1d4ed8; @@ -884,6 +914,9 @@ header .logout-btn:hover { flex-direction: column; align-items: flex-start; } + .workspace-management-header { + flex-direction: column; + } .overview-stats { grid-template-columns: 1fr; } diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index 7e4dd8d079..5cb61cc8a2 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -10,18 +10,63 @@ var input = getElement(id); if (input) input.focus(); } - function renderWorkspaceMenus(accountID, entry) { - var menus = document.querySelectorAll('[data-workspace-menu-account-id="' + accountID + '"]'); - for (var i = 0; i < menus.length; i += 1) { - var menu = menus[i]; - var workspaceID = menu.getAttribute("data-workspace-id") || ""; - var open = entry.openWorkspaceMenuID === workspaceID; - menu.hidden = !open; - var button = getElement("workspace-menu-button-" + accountID + "-" + workspaceID); - if (button) { - button.setAttribute("aria-expanded", open ? "true" : "false"); + function workspaceActionLabel(workspace) { + return workspace.state === "active" ? "Suspend workspace" : "Delete workspace"; + } + function workspaceSummary(workspace) { + if (workspace.health_status === "healthy") return "Live updates and health checks are currently good."; + if (workspace.health_status === "unhealthy") return "This workspace needs attention before it is trustworthy."; + return "This workspace is still waiting on a completed health check."; + } + function workspaceMeta(workspace) { + var parts = [workspace.state]; + if (workspace.health_status) parts.push(workspace.health_status); + if (workspace.created_at) { + var date = new Date(workspace.created_at); + if (!Number.isNaN(date.getTime())) { + parts.push("Created " + date.toLocaleDateString(void 0, { month: "short", day: "numeric", year: "numeric" })); } } + return parts.join(" \xB7 "); + } + function findWorkspace(account, workspaceID) { + for (var i = 0; i < account.workspaces.length; i += 1) { + if (account.workspaces[i].id === workspaceID) return account.workspaces[i]; + } + return null; + } + function renderWorkspaceManagement(account, entry) { + var panel = getElement("workspace-management-" + account.id); + if (!panel) return; + var empty = getElement("workspace-management-empty-" + account.id); + var content = getElement("workspace-management-content-" + account.id); + var title = getElement("workspace-management-title-" + account.id); + var meta = getElement("workspace-management-meta-" + account.id); + var summary = getElement("workspace-management-summary-" + account.id); + var actionButton = getElement("workspace-management-action-" + account.id); + var closeButton = getElement("workspace-management-close-" + account.id); + if (!empty || !content || !title || !meta || !summary || !actionButton || !closeButton) return; + var workspace = entry.selectedWorkspaceID ? findWorkspace(account, entry.selectedWorkspaceID) : null; + var hasSelection = !!workspace; + panel.classList.toggle("visible", hasSelection); + empty.hidden = hasSelection; + content.hidden = !hasSelection; + if (!workspace) { + actionButton.disabled = false; + actionButton.removeAttribute("data-workspace-id"); + actionButton.removeAttribute("data-workspace-name"); + actionButton.removeAttribute("data-workspace-action"); + return; + } + title.textContent = workspace.display_name; + meta.textContent = workspaceMeta(workspace); + summary.textContent = workspaceSummary(workspace); + actionButton.textContent = workspaceActionLabel(workspace); + actionButton.disabled = entry.manageWorkspace.pending; + actionButton.setAttribute("data-workspace-id", workspace.id); + actionButton.setAttribute("data-workspace-name", workspace.display_name); + actionButton.setAttribute("data-workspace-action", workspace.state === "active" ? "suspend" : "delete"); + closeButton.disabled = entry.manageWorkspace.pending; } function setTbodyMessage(tbody, msg, isError) { tbody.textContent = ""; @@ -113,13 +158,20 @@ tbody.appendChild(tr); } } - function renderAccountUI(accountState) { + function renderAccountUI(accountState, accounts) { var accountIDs = Object.keys(accountState.byAccountID); for (var i = 0; i < accountIDs.length; i += 1) { var accountID = accountIDs[i]; var entry = accountState.byAccountID[accountID]; + var account = null; + for (var j = 0; j < accounts.length; j += 1) { + if (accounts[j].id === accountID) { + account = accounts[j]; + break; + } + } renderAddWorkspaceSection(accountID, entry); - renderWorkspaceMenus(accountID, entry); + if (account) renderWorkspaceManagement(account, entry); renderTeamSection(accountID, entry); } } @@ -130,9 +182,6 @@ var target = asHTMLElement(event.target); if (!target) return; var actionEl = target.closest("[data-action]"); - if (!target.closest(".workspace-menu-wrap")) { - deps.runtime.closeWorkspaceMenus(); - } if (!actionEl) return; var action = actionEl.getAttribute("data-action") || ""; var accountID = actionEl.getAttribute("data-account-id") || ""; @@ -157,13 +206,17 @@ event.preventDefault(); void deps.runtime.createWorkspace(accountID); return; - case "toggle-workspace-menu": + case "select-workspace": event.preventDefault(); - deps.runtime.toggleWorkspaceMenu( + deps.runtime.selectWorkspace( accountID, actionEl.getAttribute("data-workspace-id") || "" ); return; + case "clear-workspace-selection": + event.preventDefault(); + deps.runtime.clearWorkspaceSelection(accountID); + return; case "workspace-action": event.preventDefault(); void deps.runtime.manageWorkspaceAction( @@ -424,7 +477,8 @@ accountState.byAccountID[accountID] = { addWorkspaceOpen: false, createWorkspace: createMutationState(), - openWorkspaceMenuID: "", + selectedWorkspaceID: "", + manageWorkspace: createMutationState(), teamVisible: false, teamQuery: createQueryState([]) }; @@ -538,15 +592,7 @@ return true; }; var renderAccountRuntime = function() { - renderAccountUI(deps.store.getAccountState()); - }; - var closeWorkspaceMenus = function() { - deps.store.updateAccountState(function(accountState) { - var accountIDs = Object.keys(accountState.byAccountID); - for (var i = 0; i < accountIDs.length; i += 1) { - accountState.byAccountID[accountIDs[i]].openWorkspaceMenuID = ""; - } - }); + renderAccountUI(deps.store.getAccountState(), deps.store.getBootstrap().accounts || []); }; var loadTeam = async function(accountID) { var section = getElement("team-section-" + accountID); @@ -588,7 +634,6 @@ var shouldFocus = false; deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); - entry.openWorkspaceMenuID = ""; entry.addWorkspaceOpen = !entry.addWorkspaceOpen; shouldFocus = entry.addWorkspaceOpen; }); @@ -596,17 +641,16 @@ focusElement("ws-name-" + accountID); } }; - var toggleWorkspaceMenu = function(accountID, workspaceID) { + var selectWorkspace = function(accountID, workspaceID) { deps.store.updateAccountState(function(accountState) { - var accountIDs = Object.keys(accountState.byAccountID); - for (var i = 0; i < accountIDs.length; i += 1) { - var entry = ensurePortalAccountUIEntry(accountState, accountIDs[i]); - if (accountIDs[i] !== accountID) { - entry.openWorkspaceMenuID = ""; - } - } var entry = ensurePortalAccountUIEntry(accountState, accountID); - entry.openWorkspaceMenuID = entry.openWorkspaceMenuID === workspaceID ? "" : workspaceID; + entry.selectedWorkspaceID = entry.selectedWorkspaceID === workspaceID ? "" : workspaceID; + }); + }; + var clearWorkspaceSelection = function(accountID) { + deps.store.updateAccountState(function(accountState) { + var entry = ensurePortalAccountUIEntry(accountState, accountID); + entry.selectedWorkspaceID = ""; }); }; var createWorkspace = async function(accountID) { @@ -633,7 +677,6 @@ deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); entry.addWorkspaceOpen = false; - entry.openWorkspaceMenuID = ""; succeedMutationState(entry.createWorkspace); }); deps.showToast("Workspace created!"); @@ -647,10 +690,13 @@ } }; var manageWorkspaceAction = async function(accountID, tenantID, action, name) { - closeWorkspaceMenus(); var verb = action === "suspend" ? "Suspend" : action === "delete" ? "Delete" : ""; if (!verb) return; if (!window.confirm(verb + ' workspace "' + name + '"?')) return; + deps.store.updateAccountState(function(accountState) { + var entry = ensurePortalAccountUIEntry(accountState, accountID); + beginMutationState(entry.manageWorkspace); + }); try { if (action === "suspend") { await deps.api.suspendWorkspace(accountID, tenantID); @@ -658,10 +704,23 @@ await deps.api.deleteWorkspace(accountID, tenantID); } if (!await refreshOrRedirect()) { + deps.store.updateAccountState(function(accountState) { + var entry = ensurePortalAccountUIEntry(accountState, accountID); + resetMutationState(entry.manageWorkspace); + }, { notify: false }); return; } + deps.store.updateAccountState(function(accountState) { + var entry = ensurePortalAccountUIEntry(accountState, accountID); + entry.selectedWorkspaceID = ""; + succeedMutationState(entry.manageWorkspace); + }); deps.showToast(verb + "ed workspace."); } catch (error) { + deps.store.updateAccountState(function(accountState) { + var entry = ensurePortalAccountUIEntry(accountState, accountID); + failMutationState(entry.manageWorkspace, error instanceof Error ? error.message : "Failed to " + verb.toLowerCase() + " workspace."); + }, { notify: false }); deps.showToast(error instanceof Error ? error.message : "Failed to " + verb.toLowerCase() + " workspace.", true); } }; @@ -681,7 +740,6 @@ var nextVisible = false; deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); - entry.openWorkspaceMenuID = ""; entry.teamVisible = !entry.teamVisible; nextVisible = entry.teamVisible; }); @@ -750,8 +808,8 @@ deps.store.subscribeBootstrap(renderAccountRuntime); return { toggleAddWorkspace, - toggleWorkspaceMenu, - closeWorkspaceMenus, + selectWorkspace, + clearWorkspaceSelection, openBilling, toggleTeam, inviteMember, @@ -1501,9 +1559,7 @@ } var manageAction = ""; if (account.can_manage && (state === "active" || state === "suspended" || state === "failed")) { - var menuAction = state === "active" ? "suspend" : "delete"; - var menuLabel = state === "active" ? "Suspend workspace" : "Delete workspace"; - manageAction = '
"; + manageAction = ''; } var createdMeta = createdLabel ? 'Created ' + escapeHTML(createdLabel) + "" : ""; return '
' + escapeHTML(workspace.display_name) + '
' + healthBadgeHTML(workspace) + '' + safeState + "" + createdMeta + '
' + escapeHTML(healthSummary) + '
' + openAction + manageAction + "
"; @@ -1527,14 +1583,16 @@ var actions = ""; var teamSection = ""; var addWorkspaceForm = ""; + var workspaceManagement = ""; if (account.can_manage) { actions = '
'; teamSection = '

Team members

EmailRole
Loading\u2026
'; + workspaceManagement = '

Workspace management

Select a workspace from the fleet to review its lifecycle state and run explicit management actions.

Choose a workspace to manage from the fleet above.
'; if (account.kind === "msp") { addWorkspaceForm = '
'; } } - return '
" + actions + '' + workspaceHTML + teamSection + addWorkspaceForm + "
"; + return '
" + actions + '' + workspaceHTML + workspaceManagement + teamSection + addWorkspaceForm + "
"; } function renderHeaderHTML(context) { if (context.bootstrap.authenticated) { diff --git a/internal/cloudcp/portal/frontend/src/account_controller.test.ts b/internal/cloudcp/portal/frontend/src/account_controller.test.ts index 4e30d22edc..a2e8aaa2d5 100644 --- a/internal/cloudcp/portal/frontend/src/account_controller.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_controller.test.ts @@ -11,8 +11,8 @@ describe('account controller', function() { it('routes account actions to the matching runtime handlers', function() { var runtime = { toggleAddWorkspace: vi.fn(), - toggleWorkspaceMenu: vi.fn(), - closeWorkspaceMenus: vi.fn(), + selectWorkspace: vi.fn(), + clearWorkspaceSelection: vi.fn(), openBilling: vi.fn(), toggleTeam: vi.fn(), inviteMember: vi.fn(), @@ -30,10 +30,9 @@ describe('account controller', function() { '' + '' + '' + - '
' + - '' + + '' + + '' + '' + - '
' + '' + ''; @@ -52,8 +51,11 @@ describe('account controller', function() { document.getElementById('create')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(runtime.createWorkspace).toHaveBeenCalledWith('acct_1'); - document.getElementById('menu')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - expect(runtime.toggleWorkspaceMenu).toHaveBeenCalledWith('acct_1', 'ws_1'); + document.getElementById('select')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(runtime.selectWorkspace).toHaveBeenCalledWith('acct_1', 'ws_1'); + + document.getElementById('close')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(runtime.clearWorkspaceSelection).toHaveBeenCalledWith('acct_1'); document.getElementById('manage')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(runtime.manageWorkspaceAction).toHaveBeenCalledWith('acct_1', 'ws_1', 'suspend', 'Alpha'); @@ -65,8 +67,5 @@ describe('account controller', function() { role.value = 'admin'; role.dispatchEvent(new Event('change', { bubbles: true })); expect(runtime.changeRole).toHaveBeenCalledWith('acct_1', 'u1', 'admin'); - - document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); - expect(runtime.closeWorkspaceMenus).toHaveBeenCalled(); }); }); diff --git a/internal/cloudcp/portal/frontend/src/account_controller.ts b/internal/cloudcp/portal/frontend/src/account_controller.ts index 14948a175d..3ae0f5b819 100644 --- a/internal/cloudcp/portal/frontend/src/account_controller.ts +++ b/internal/cloudcp/portal/frontend/src/account_controller.ts @@ -10,9 +10,6 @@ export function installAccountController(deps: AccountControllerDeps): void { var target = asHTMLElement(event.target); if (!target) return; var actionEl = target.closest('[data-action]'); - if (!target.closest('.workspace-menu-wrap')) { - deps.runtime.closeWorkspaceMenus(); - } if (!actionEl) return; var action = actionEl.getAttribute('data-action') || ''; var accountID = actionEl.getAttribute('data-account-id') || ''; @@ -38,13 +35,17 @@ export function installAccountController(deps: AccountControllerDeps): void { event.preventDefault(); void deps.runtime.createWorkspace(accountID); return; - case 'toggle-workspace-menu': + case 'select-workspace': event.preventDefault(); - deps.runtime.toggleWorkspaceMenu( + deps.runtime.selectWorkspace( accountID, actionEl.getAttribute('data-workspace-id') || '', ); return; + case 'clear-workspace-selection': + event.preventDefault(); + deps.runtime.clearWorkspaceSelection(accountID); + return; case 'workspace-action': event.preventDefault(); void deps.runtime.manageWorkspaceAction( diff --git a/internal/cloudcp/portal/frontend/src/account_runtime.test.ts b/internal/cloudcp/portal/frontend/src/account_runtime.test.ts index 33d989b777..e49b48e91b 100644 --- a/internal/cloudcp/portal/frontend/src/account_runtime.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_runtime.test.ts @@ -81,6 +81,7 @@ describe('account runtime', function() { '
' + '' + '' + + '
' + '
'; runtime.toggleAddWorkspace('acct_1'); @@ -106,25 +107,47 @@ describe('account runtime', function() { expect((document.getElementById('ws-spinner-acct_1') as HTMLElement).hidden).toBe(true); }); - it('toggles workspace menus and routes explicit workspace actions', async function() { + it('selects a workspace and routes explicit workspace actions through the management panel', async function() { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({}))); var confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + deps.store.setBootstrap({ + authenticated: true, + email: 'owner@example.com', + accounts: [{ + id: 'acct_1', + name: 'Acme MSP', + kind: 'msp', + kind_label: 'MSP', + role: 'owner', + can_manage: true, + has_billing: true, + workspaces: [{ + id: 'ws_2', + display_name: 'Alpha Workspace', + state: 'active', + healthy: true, + health_status: 'healthy', + }], + }], + }); document.body.innerHTML = - '' + - '' + - '' + - ''; + '
' + + '' + + '
' + + '' + + '
'; - runtime.toggleWorkspaceMenu('acct_1', 'ws_1'); - expect(deps.store.getAccountState().byAccountID.acct_1.openWorkspaceMenuID).toBe('ws_1'); - expect((document.getElementById('workspace-menu-acct_1-ws_1') as HTMLElement).hidden).toBe(false); + runtime.selectWorkspace('acct_1', 'ws_2'); + expect(deps.store.getAccountState().byAccountID.acct_1.selectedWorkspaceID).toBe('ws_2'); + expect(document.getElementById('workspace-management-title-acct_1')?.textContent).toContain('Alpha Workspace'); + expect(document.getElementById('workspace-management-action-acct_1')?.textContent).toContain('Suspend workspace'); - runtime.toggleWorkspaceMenu('acct_1', 'ws_1'); - expect(deps.store.getAccountState().byAccountID.acct_1.openWorkspaceMenuID).toBe(''); - expect((document.getElementById('workspace-menu-acct_1-ws_1') as HTMLElement).hidden).toBe(true); - - runtime.toggleWorkspaceMenu('acct_1', 'ws_2'); await runtime.manageWorkspaceAction('acct_1', 'ws_2', 'suspend', 'Alpha Workspace'); await flushAsync(); @@ -138,7 +161,7 @@ describe('account runtime', function() { }) ); expect(deps.showToast).toHaveBeenCalledWith('Suspended workspace.'); - expect(deps.store.getAccountState().byAccountID.acct_1.openWorkspaceMenuID).toBe(''); + expect(deps.store.getAccountState().byAccountID.acct_1.selectedWorkspaceID).toBe(''); }); it('loads and updates team membership from runtime actions', async function() { @@ -151,6 +174,7 @@ describe('account runtime', function() { ); document.body.innerHTML = + '
' + '
' + '
' + '' + diff --git a/internal/cloudcp/portal/frontend/src/account_runtime.ts b/internal/cloudcp/portal/frontend/src/account_runtime.ts index ac147efeae..3a455061bb 100644 --- a/internal/cloudcp/portal/frontend/src/account_runtime.ts +++ b/internal/cloudcp/portal/frontend/src/account_runtime.ts @@ -15,8 +15,8 @@ export interface AccountRuntimeDeps { export interface AccountRuntime { toggleAddWorkspace(accountID: string): void; - toggleWorkspaceMenu(accountID: string, workspaceID: string): void; - closeWorkspaceMenus(): void; + selectWorkspace(accountID: string, workspaceID: string): void; + clearWorkspaceSelection(accountID: string): void; openBilling(accountID: string): Promise; toggleTeam(accountID: string): void; inviteMember(accountID: string): Promise; @@ -40,16 +40,7 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime }; var renderAccountRuntime = function(): void { - renderAccountUIState(deps.store.getAccountState()); - }; - - var closeWorkspaceMenus = function(): void { - deps.store.updateAccountState(function(accountState) { - var accountIDs = Object.keys(accountState.byAccountID); - for (var i = 0; i < accountIDs.length; i += 1) { - accountState.byAccountID[accountIDs[i]].openWorkspaceMenuID = ''; - } - }); + renderAccountUIState(deps.store.getAccountState(), deps.store.getBootstrap().accounts || []); }; var loadTeam = async function(accountID: string): Promise { @@ -94,7 +85,6 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime var shouldFocus = false; deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); - entry.openWorkspaceMenuID = ''; entry.addWorkspaceOpen = !entry.addWorkspaceOpen; shouldFocus = entry.addWorkspaceOpen; }); @@ -103,17 +93,17 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime } }; - var toggleWorkspaceMenu = function(accountID: string, workspaceID: string): void { + var selectWorkspace = function(accountID: string, workspaceID: string): void { deps.store.updateAccountState(function(accountState) { - var accountIDs = Object.keys(accountState.byAccountID); - for (var i = 0; i < accountIDs.length; i += 1) { - var entry = ensurePortalAccountUIEntry(accountState, accountIDs[i]); - if (accountIDs[i] !== accountID) { - entry.openWorkspaceMenuID = ''; - } - } var entry = ensurePortalAccountUIEntry(accountState, accountID); - entry.openWorkspaceMenuID = entry.openWorkspaceMenuID === workspaceID ? '' : workspaceID; + entry.selectedWorkspaceID = entry.selectedWorkspaceID === workspaceID ? '' : workspaceID; + }); + }; + + var clearWorkspaceSelection = function(accountID: string): void { + deps.store.updateAccountState(function(accountState) { + var entry = ensurePortalAccountUIEntry(accountState, accountID); + entry.selectedWorkspaceID = ''; }); }; @@ -141,7 +131,6 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); entry.addWorkspaceOpen = false; - entry.openWorkspaceMenuID = ''; succeedMutationState(entry.createWorkspace); }); deps.showToast('Workspace created!'); @@ -156,10 +145,13 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime }; var manageWorkspaceAction = async function(accountID: string, tenantID: string, action: string, name: string): Promise { - closeWorkspaceMenus(); var verb = action === 'suspend' ? 'Suspend' : action === 'delete' ? 'Delete' : ''; if (!verb) return; if (!window.confirm(verb + ' workspace "' + name + '"?')) return; + deps.store.updateAccountState(function(accountState) { + var entry = ensurePortalAccountUIEntry(accountState, accountID); + beginMutationState(entry.manageWorkspace); + }); try { if (action === 'suspend') { await deps.api.suspendWorkspace(accountID, tenantID); @@ -167,10 +159,23 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime await deps.api.deleteWorkspace(accountID, tenantID); } if (!await refreshOrRedirect()) { + deps.store.updateAccountState(function(accountState) { + var entry = ensurePortalAccountUIEntry(accountState, accountID); + resetMutationState(entry.manageWorkspace); + }, { notify: false }); return; } + deps.store.updateAccountState(function(accountState) { + var entry = ensurePortalAccountUIEntry(accountState, accountID); + entry.selectedWorkspaceID = ''; + succeedMutationState(entry.manageWorkspace); + }); deps.showToast(verb + 'ed workspace.'); } catch (error) { + deps.store.updateAccountState(function(accountState) { + var entry = ensurePortalAccountUIEntry(accountState, accountID); + failMutationState(entry.manageWorkspace, error instanceof Error ? error.message : 'Failed to ' + verb.toLowerCase() + ' workspace.'); + }, { notify: false }); deps.showToast(error instanceof Error ? error.message : 'Failed to ' + verb.toLowerCase() + ' workspace.', true); } }; @@ -192,7 +197,6 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime var nextVisible = false; deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); - entry.openWorkspaceMenuID = ''; entry.teamVisible = !entry.teamVisible; nextVisible = entry.teamVisible; }); @@ -266,8 +270,8 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime return { toggleAddWorkspace: toggleAddWorkspace, - toggleWorkspaceMenu: toggleWorkspaceMenu, - closeWorkspaceMenus: closeWorkspaceMenus, + selectWorkspace: selectWorkspace, + clearWorkspaceSelection: clearWorkspaceSelection, openBilling: openBilling, toggleTeam: toggleTeam, inviteMember: inviteMember, diff --git a/internal/cloudcp/portal/frontend/src/account_view.test.ts b/internal/cloudcp/portal/frontend/src/account_view.test.ts index a2f14903e7..8df91f0d46 100644 --- a/internal/cloudcp/portal/frontend/src/account_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_view.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { renderAccountUI, renderAddWorkspaceSection, renderTeamSection, renderWorkspaceMenus } from './account_view'; -import type { PortalAccountState, PortalAccountUIEntry } from './types'; +import { renderAccountUI, renderAddWorkspaceSection, renderTeamSection, renderWorkspaceManagement } from './account_view'; +import type { PortalAccountState, PortalAccountSummary, PortalAccountUIEntry } from './types'; function createEntry(overrides: Partial = {}): PortalAccountUIEntry { return { @@ -10,7 +10,11 @@ function createEntry(overrides: Partial = {}): PortalAccou pending: false, error: '', }, - openWorkspaceMenuID: '', + selectedWorkspaceID: '', + manageWorkspace: { + pending: false, + error: '', + }, teamVisible: false, teamQuery: { status: 'idle', @@ -26,6 +30,29 @@ describe('account view', function() { document.body.innerHTML = ''; }); + function createAccount(overrides: Partial = {}): PortalAccountSummary { + return { + id: 'acct_1', + name: 'Acme MSP', + kind: 'msp', + kind_label: 'MSP', + role: 'owner', + can_manage: true, + has_billing: true, + workspaces: [ + { + id: 'ws_1', + display_name: 'Alpha Workspace', + state: 'active', + healthy: true, + health_status: 'healthy', + created_at: '2026-03-26T10:00:00Z', + }, + ], + ...overrides, + }; + } + it('renders add-workspace visibility from account UI state', function() { document.body.innerHTML = '
' + @@ -78,25 +105,34 @@ describe('account view', function() { expect(document.querySelector('[data-action="remove-member"]')).not.toBeNull(); }); - it('renders workspace menu visibility from account UI state', function() { + it('renders workspace management selection from account UI state', function() { document.body.innerHTML = - '' + - '' + - '' + - ''; + '
' + + '' + + '
' + + '' + + '
'; - renderWorkspaceMenus('acct_1', createEntry({ openWorkspaceMenuID: 'ws_2' })); - expect((document.getElementById('workspace-menu-acct_1-ws_1') as HTMLElement).hidden).toBe(true); - expect((document.getElementById('workspace-menu-acct_1-ws_2') as HTMLElement).hidden).toBe(false); - expect(document.getElementById('workspace-menu-button-acct_1-ws_1')?.getAttribute('aria-expanded')).toBe('false'); - expect(document.getElementById('workspace-menu-button-acct_1-ws_2')?.getAttribute('aria-expanded')).toBe('true'); + renderWorkspaceManagement(createAccount(), createEntry({ selectedWorkspaceID: 'ws_1' })); + expect(document.getElementById('workspace-management-acct_1')?.classList.contains('visible')).toBe(true); + expect((document.getElementById('workspace-management-empty-acct_1') as HTMLElement).hidden).toBe(true); + expect((document.getElementById('workspace-management-content-acct_1') as HTMLElement).hidden).toBe(false); + expect(document.getElementById('workspace-management-title-acct_1')?.textContent).toContain('Alpha Workspace'); + expect(document.getElementById('workspace-management-action-acct_1')?.textContent).toContain('Suspend workspace'); }); it('renders account UI for every tracked account entry', function() { document.body.innerHTML = '
' + + '
' + '
' + '
' + + '
' + '
'; var state: PortalAccountState = { @@ -104,6 +140,7 @@ describe('account view', function() { acct_1: createEntry({ addWorkspaceOpen: true, teamVisible: true }), acct_2: createEntry({ addWorkspaceOpen: false, + selectedWorkspaceID: 'ws_2', teamVisible: true, teamQuery: { status: 'ready', @@ -114,10 +151,26 @@ describe('account view', function() { }, }; - renderAccountUI(state); + renderAccountUI(state, [ + createAccount(), + createAccount({ + id: 'acct_2', + name: 'Beta MSP', + workspaces: [ + { + id: 'ws_2', + display_name: 'Beta Workspace', + state: 'failed', + healthy: false, + health_status: 'unhealthy', + }, + ], + }), + ]); expect(document.getElementById('add-ws-form-acct_1')?.classList.contains('visible')).toBe(true); expect(document.getElementById('add-ws-form-acct_2')?.classList.contains('visible')).toBe(false); expect(document.getElementById('team-list-acct_2')?.textContent).toContain('tech@example.com'); + expect(document.getElementById('workspace-management-title-acct_2')?.textContent).toContain('Beta Workspace'); }); }); diff --git a/internal/cloudcp/portal/frontend/src/account_view.ts b/internal/cloudcp/portal/frontend/src/account_view.ts index ecaf320abc..5b2ccc9b83 100644 --- a/internal/cloudcp/portal/frontend/src/account_view.ts +++ b/internal/cloudcp/portal/frontend/src/account_view.ts @@ -1,4 +1,10 @@ -import type { PortalAccountState, PortalAccountUIEntry, PortalTeamMember } from './types'; +import type { + PortalAccountState, + PortalAccountSummary, + PortalAccountUIEntry, + PortalTeamMember, + PortalWorkspaceSummary, +} from './types'; type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; @@ -15,18 +21,70 @@ export function focusElement(id: string): void { if (input) input.focus(); } -export function renderWorkspaceMenus(accountID: string, entry: PortalAccountUIEntry): void { - var menus = document.querySelectorAll('[data-workspace-menu-account-id="' + accountID + '"]'); - for (var i = 0; i < menus.length; i += 1) { - var menu = menus[i]; - var workspaceID = menu.getAttribute('data-workspace-id') || ''; - var open = entry.openWorkspaceMenuID === workspaceID; - menu.hidden = !open; - var button = getElement('workspace-menu-button-' + accountID + '-' + workspaceID); - if (button) { - button.setAttribute('aria-expanded', open ? 'true' : 'false'); +function workspaceActionLabel(workspace: PortalWorkspaceSummary): string { + return workspace.state === 'active' ? 'Suspend workspace' : 'Delete workspace'; +} + +function workspaceSummary(workspace: PortalWorkspaceSummary): string { + if (workspace.health_status === 'healthy') return 'Live updates and health checks are currently good.'; + if (workspace.health_status === 'unhealthy') return 'This workspace needs attention before it is trustworthy.'; + return 'This workspace is still waiting on a completed health check.'; +} + +function workspaceMeta(workspace: PortalWorkspaceSummary): string { + var parts = [workspace.state]; + if (workspace.health_status) parts.push(workspace.health_status); + if (workspace.created_at) { + var date = new Date(workspace.created_at); + if (!Number.isNaN(date.getTime())) { + parts.push('Created ' + date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })); } } + return parts.join(' ยท '); +} + +function findWorkspace(account: PortalAccountSummary, workspaceID: string): PortalWorkspaceSummary | null { + for (var i = 0; i < account.workspaces.length; i += 1) { + if (account.workspaces[i].id === workspaceID) return account.workspaces[i]; + } + return null; +} + +export function renderWorkspaceManagement(account: PortalAccountSummary, entry: PortalAccountUIEntry): void { + var panel = getElement('workspace-management-' + account.id); + if (!panel) return; + var empty = getElement('workspace-management-empty-' + account.id); + var content = getElement('workspace-management-content-' + account.id); + var title = getElement('workspace-management-title-' + account.id); + var meta = getElement('workspace-management-meta-' + account.id); + var summary = getElement('workspace-management-summary-' + account.id); + var actionButton = getElement('workspace-management-action-' + account.id); + var closeButton = getElement('workspace-management-close-' + account.id); + if (!empty || !content || !title || !meta || !summary || !actionButton || !closeButton) return; + + var workspace = entry.selectedWorkspaceID ? findWorkspace(account, entry.selectedWorkspaceID) : null; + var hasSelection = !!workspace; + panel.classList.toggle('visible', hasSelection); + empty.hidden = hasSelection; + content.hidden = !hasSelection; + + if (!workspace) { + actionButton.disabled = false; + actionButton.removeAttribute('data-workspace-id'); + actionButton.removeAttribute('data-workspace-name'); + actionButton.removeAttribute('data-workspace-action'); + return; + } + + title.textContent = workspace.display_name; + meta.textContent = workspaceMeta(workspace); + summary.textContent = workspaceSummary(workspace); + actionButton.textContent = workspaceActionLabel(workspace); + actionButton.disabled = entry.manageWorkspace.pending; + actionButton.setAttribute('data-workspace-id', workspace.id); + actionButton.setAttribute('data-workspace-name', workspace.display_name); + actionButton.setAttribute('data-workspace-action', workspace.state === 'active' ? 'suspend' : 'delete'); + closeButton.disabled = entry.manageWorkspace.pending; } function setTbodyMessage(tbody: HTMLElement, msg: string, isError: boolean): void { @@ -129,13 +187,20 @@ export function renderTeamSection(accountID: string, entry: PortalAccountUIEntry } } -export function renderAccountUI(accountState: PortalAccountState): void { +export function renderAccountUI(accountState: PortalAccountState, accounts: PortalAccountSummary[]): void { var accountIDs = Object.keys(accountState.byAccountID); for (var i = 0; i < accountIDs.length; i += 1) { var accountID = accountIDs[i]; var entry = accountState.byAccountID[accountID]; + var account = null as PortalAccountSummary | null; + for (var j = 0; j < accounts.length; j += 1) { + if (accounts[j].id === accountID) { + account = accounts[j]; + break; + } + } renderAddWorkspaceSection(accountID, entry); - renderWorkspaceMenus(accountID, entry); + if (account) renderWorkspaceManagement(account, entry); renderTeamSection(accountID, entry); } } diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts index 832e0a6cab..63dca4b3a8 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts @@ -147,7 +147,10 @@ describe('shell view', function() { expect(html).toContain('This workspace is still waiting on a completed health check.'); expect(html).toContain('/api/accounts/acct_1/tenants/ws_active/handoff'); expect(html).toContain('Open workspace'); - expect(html).toContain('data-action="toggle-workspace-menu"'); + expect(html).toContain('data-action="select-workspace"'); + expect(html).toContain('Workspace management'); + expect(html).toContain('Choose a workspace to manage from the fleet above.'); + expect(html).toContain('data-action="clear-workspace-selection"'); expect(html).toContain('data-action="workspace-action"'); expect(html).toContain('service-card-button'); expect(html).toContain('id="open-retrieve-service"'); diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts index 29f3928f2c..10b2f06a8e 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.ts @@ -152,41 +152,14 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor var manageAction = ''; if (account.can_manage && (state === 'active' || state === 'suspended' || state === 'failed')) { - var menuAction = state === 'active' ? 'suspend' : 'delete'; - var menuLabel = state === 'active' ? 'Suspend workspace' : 'Delete workspace'; manageAction = - '
' + - '' + - '' + - '
'; + '">' + + 'Manage' + + ''; } var createdMeta = createdLabel ? 'Created ' + escapeHTML(createdLabel) + '' : ''; @@ -236,6 +209,7 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath: var actions = ''; var teamSection = ''; var addWorkspaceForm = ''; + var workspaceManagement = ''; if (account.can_manage) { actions = '' + '
'; + workspaceManagement = + '
' + + '
' + + '
' + + '

Workspace management

' + + '

Select a workspace from the fleet to review its lifecycle state and run explicit management actions.

' + + '
' + + '' + + '
' + + '
Choose a workspace to manage from the fleet above.
' + + '' + + '
'; + if (account.kind === 'msp') { addWorkspaceForm = '