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 = 'Account operations
' + escapeHTML(operationsCopy) + '
' + (account.kind === "msp" ? '' : "") + (account.has_billing ? '' : "") + '
';
teamSection = '';
+ workspaceManagement = 'Choose a workspace to manage from the fleet above.
';
if (account.kind === "msp") {
addWorkspaceForm = '';
}
}
- return 'Active workspaces' + String(activeCount) + '
Healthy' + String(healthyCount) + '
Checking' + String(checkingCount) + '
Needs attention' + String(unhealthyCount) + "
" + actions + '' + workspaceHTML + teamSection + addWorkspaceForm + "";
+ return 'Active workspaces' + String(activeCount) + '
Healthy' + String(healthyCount) + '
Checking' + String(checkingCount) + '
Needs attention' + String(unhealthyCount) + "
" + 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 =
'' +
@@ -296,6 +270,46 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath:
'
' +
' ';
+ workspaceManagement =
+ '' +
+ '' +
+ '
Choose a workspace to manage from the fleet above.
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '' +
+ '
' +
+ '
' +
+ '
';
+
if (account.kind === 'msp') {
addWorkspaceForm =
'