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 85b07804d3..7ac1328a5b 100644 --- a/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md +++ b/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md @@ -1,6 +1,6 @@ # Pulse Account Portal Spec -Last updated: 2026-03-26 +Last updated: 2026-03-27 Status: ACTIVE ## Purpose @@ -135,6 +135,11 @@ Core rules: 7. Self-hosted billing/license tools belong in the same account shell, but they must present as a secondary account-services area when hosted access is active. +8. Hosted and MSP accounts must expose account-level operations separately from + individual workspace cards so operator actions are visible before a user + 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. ## Screen Model @@ -158,9 +163,14 @@ The signed-in shell should be treated as four first-class states: Each signed-in state should render: 1. a concise overview band that summarizes the account posture -2. account cards with role, account kind, and workspace totals -3. explicit action groups, not anonymous menu affordances -4. a clearly separated `Self-hosted licenses and billing` section when those +2. account cards with role, account kind, workspace totals, and account-level + fleet status +3. an explicit `Account operations` area for billing, team, and workspace + management entry points +4. a `Workspace fleet` area that makes health and lifecycle state legible + before the user opens a workspace +5. explicit action groups, not anonymous menu affordances +6. a clearly separated `Self-hosted licenses and billing` section when those tools are present below hosted content ## Product-Specific Boundaries diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index 9c37602b83..9998575396 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "ade0e3202886602f0f46ca656041930d213b8dc6ea3d681664a6596f18da0c12", + "source_hash": "f091b6dbbdf775a750bbba5c4047fbba2553fa0393420916e2e9e5ca315e1437", "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 5b687d41c9..9e628e2ca8 100644 --- a/internal/cloudcp/portal/dist/portal_app.css +++ b/internal/cloudcp/portal/dist/portal_app.css @@ -110,6 +110,43 @@ header .logout-btn:hover { color: #64748b; max-width: 520px; } +.account-status-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin: 0 0 16px; +} +.account-stat-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 6px; + box-shadow: 0 1px 3px rgba(15, 23, 42, .04); +} +.account-stat-label { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #64748b; +} +.account-stat-value { + font-size: 24px; + font-weight: 700; + color: #0f172a; +} +.account-stat-healthy { + color: #166534; +} +.account-stat-checking { + color: #1d4ed8; +} +.account-stat-unhealthy { + color: #b91c1c; +} .badge { font-size: 11px; font-weight: 600; @@ -194,10 +231,16 @@ header .logout-btn:hover { .ws-info { flex: 1; min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; +} +.ws-title-row { display: flex; align-items: center; gap: 10px; - flex-wrap: wrap; + width: 100%; } .ws-name { font-weight: 600; @@ -209,6 +252,11 @@ header .logout-btn:hover { gap: 6px; flex-wrap: wrap; } +.ws-summary { + font-size: 13px; + color: #475569; + line-height: 1.45; +} .ws-created { font-size: 11px; color: #94a3b8; @@ -305,10 +353,39 @@ header .logout-btn:hover { .link-button { text-decoration: none; } +.account-operations-panel { + margin: 0 0 18px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 16px 18px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(15, 23, 42, .04); +} +.account-operations-copy { + display: flex; + flex-direction: column; + gap: 4px; + max-width: 540px; +} +.account-operations-copy h3 { + margin: 0; + font-size: 15px; + font-weight: 700; +} +.account-operations-copy p { + margin: 0; + font-size: 13px; + color: #64748b; + line-height: 1.5; +} .account-actions { - margin-top: 14px; display: flex; align-items: center; + justify-content: flex-end; gap: 12px; flex-wrap: wrap; } @@ -794,6 +871,15 @@ header .logout-btn:hover { .account-badges { justify-content: flex-start; } + .account-status-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .account-operations-panel { + flex-direction: column; + } + .account-actions { + justify-content: flex-start; + } .account-subsection-header { flex-direction: column; align-items: flex-start; @@ -803,6 +889,9 @@ header .logout-btn:hover { } } @media (max-width: 560px) { + .account-status-grid { + grid-template-columns: 1fr; + } .workspace-card { flex-direction: column; align-items: flex-start; diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index a047a76689..7e4dd8d079 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -1464,6 +1464,20 @@ function hasHostedAccounts(accounts) { return accounts.length > 0; } + function countWorkspacesByHealth(workspaces, status) { + var count = 0; + for (var i = 0; i < workspaces.length; i += 1) { + if (String(workspaces[i].health_status || "") === status) count += 1; + } + return count; + } + function countWorkspacesByState(workspaces, state) { + var count = 0; + for (var i = 0; i < workspaces.length; i += 1) { + if (String(workspaces[i].state || "") === state) count += 1; + } + return count; + } function renderOverviewBand(context, accounts) { var hosted = hasHostedAccounts(accounts); var workspaceTotal = countWorkspaces(accounts); @@ -1478,6 +1492,7 @@ var state = String(workspace.state || ""); var safeState = escapeHTML(state); var createdLabel = formatWorkspaceDate(workspace.created_at); + var healthSummary = workspace.health_status === "healthy" ? "Live updates and health checks are currently good." : workspace.health_status === "unhealthy" ? "This workspace needs attention before it is trustworthy." : "This workspace is still waiting on a completed health check."; var openAction = ""; if (state === "active") { openAction = '
'; @@ -1491,11 +1506,16 @@ manageAction = '
"; } var createdMeta = createdLabel ? 'Created ' + escapeHTML(createdLabel) + "" : ""; - return '
' + escapeHTML(workspace.display_name) + '
' + healthBadgeHTML(workspace) + '' + safeState + "" + createdMeta + '
' + openAction + manageAction + "
"; + return '
' + escapeHTML(workspace.display_name) + '
' + healthBadgeHTML(workspace) + '' + safeState + "" + createdMeta + '
' + escapeHTML(healthSummary) + '
' + openAction + manageAction + "
"; } function renderAccountSection(account, accountAPIBasePath) { var workspaces = Array.isArray(account.workspaces) ? account.workspaces : []; var summaryText = accountKindLabel(account) + " \xB7 " + titleCase(account.role) + " \xB7 " + workspaceCountLabel(workspaces.length); + var healthyCount = countWorkspacesByHealth(workspaces, "healthy"); + var checkingCount = countWorkspacesByHealth(workspaces, "checking"); + var unhealthyCount = countWorkspacesByHealth(workspaces, "unhealthy"); + var activeCount = countWorkspacesByState(workspaces, "active"); + var operationsCopy = account.kind === "msp" ? "Manage the client fleet from this account surface. Workspace creation, billing, and team actions belong here." : "Use this account surface to open hosted workspaces, manage billing, and control access for this hosted account."; var workspaceHTML = ""; if (workspaces.length === 0) { workspaceHTML = '

No hosted workspaces yet. Create one to get started.

'; @@ -1508,13 +1528,13 @@ var teamSection = ""; var addWorkspaceForm = ""; if (account.can_manage) { - actions = '
' + (account.kind === "msp" ? '' : "") + (account.has_billing ? '' : "") + '
'; + actions = '

Account operations

' + escapeHTML(operationsCopy) + '

' + (account.kind === "msp" ? '' : "") + (account.has_billing ? '' : "") + '
'; teamSection = '

Team members

EmailRole
Loading\u2026
'; if (account.kind === "msp") { addWorkspaceForm = '
'; } } - return '

Workspaces

Open hosted Pulse workspaces and manage lifecycle actions for this account.

' + workspaceHTML + actions + teamSection + addWorkspaceForm + "
"; + return '
" + actions + '

Workspace fleet

Open hosted Pulse workspaces, review fleet health, and manage lifecycle actions for this account.

' + workspaceHTML + teamSection + addWorkspaceForm + "
"; } function renderHeaderHTML(context) { if (context.bootstrap.authenticated) { diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts index 7315a4a674..832e0a6cab 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts @@ -131,12 +131,20 @@ describe('shell view', function() { expect(html).toContain('MSP account'); expect(html).toContain('MSP account · Owner · 3 workspaces'); expect(html).toContain('Acme MSP'); + expect(html).toContain('Account operations'); + expect(html).toContain('Manage the client fleet from this account surface.'); + expect(html).toContain('Workspace fleet'); expect(html).toContain('Alpha Workspace'); expect(html).toContain('Beta Workspace'); expect(html).toContain('Gamma Workspace'); + expect(html).toContain('Active workspaces'); + expect(html).toContain('Needs attention'); expect(html).toContain('Healthy'); expect(html).toContain('Unhealthy'); expect(html).toContain('Checking'); + expect(html).toContain('Live updates and health checks are currently good.'); + expect(html).toContain('This workspace needs attention before it is trustworthy.'); + 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"'); diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts index e9df631429..29f3928f2c 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.ts @@ -77,6 +77,22 @@ function hasHostedAccounts(accounts: PortalAccountSummary[]): boolean { return accounts.length > 0; } +function countWorkspacesByHealth(workspaces: PortalWorkspaceSummary[], status: string): number { + var count = 0; + for (var i = 0; i < workspaces.length; i += 1) { + if (String(workspaces[i].health_status || '') === status) count += 1; + } + return count; +} + +function countWorkspacesByState(workspaces: PortalWorkspaceSummary[], state: string): number { + var count = 0; + for (var i = 0; i < workspaces.length; i += 1) { + if (String(workspaces[i].state || '') === state) count += 1; + } + return count; +} + function renderOverviewBand(context: ShellViewContext, accounts: PortalAccountSummary[]): string { var hosted = hasHostedAccounts(accounts); var workspaceTotal = countWorkspaces(accounts); @@ -117,6 +133,11 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor var state = String(workspace.state || ''); var safeState = escapeHTML(state); var createdLabel = formatWorkspaceDate(workspace.created_at); + var healthSummary = workspace.health_status === 'healthy' + ? 'Live updates and health checks are currently good.' + : workspace.health_status === 'unhealthy' + ? 'This workspace needs attention before it is trustworthy.' + : 'This workspace is still waiting on a completed health check.'; var openAction = ''; if (state === 'active') { openAction = @@ -172,12 +193,15 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor return ( '
' + '
' + - '' + escapeHTML(workspace.display_name) + '' + + '
' + + '' + escapeHTML(workspace.display_name) + '' + + '
' + '
' + healthBadgeHTML(workspace) + '' + safeState + '' + createdMeta + '
' + + '
' + escapeHTML(healthSummary) + '
' + '
' + '
' + openAction + @@ -190,6 +214,13 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath: string): string { var workspaces = Array.isArray(account.workspaces) ? account.workspaces : []; var summaryText = accountKindLabel(account) + ' · ' + titleCase(account.role) + ' · ' + workspaceCountLabel(workspaces.length); + var healthyCount = countWorkspacesByHealth(workspaces, 'healthy'); + var checkingCount = countWorkspacesByHealth(workspaces, 'checking'); + var unhealthyCount = countWorkspacesByHealth(workspaces, 'unhealthy'); + var activeCount = countWorkspacesByState(workspaces, 'active'); + var operationsCopy = account.kind === 'msp' + ? 'Manage the client fleet from this account surface. Workspace creation, billing, and team actions belong here.' + : 'Use this account surface to open hosted workspaces, manage billing, and control access for this hosted account.'; var workspaceHTML = ''; if (workspaces.length === 0) { workspaceHTML = '

No hosted workspaces yet. Create one to get started.

'; @@ -207,13 +238,18 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath: var addWorkspaceForm = ''; if (account.can_manage) { actions = + ''; teamSection = @@ -298,12 +335,30 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath: roleBadgeHTML(account.role) + '
' + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + actions + '
' + - '

Workspaces

' + - '

Open hosted Pulse workspaces and manage lifecycle actions for this account.

' + + '

Workspace fleet

' + + '

Open hosted Pulse workspaces, review fleet health, and manage lifecycle actions for this account.

' + '
' + workspaceHTML + - actions + teamSection + addWorkspaceForm + '' diff --git a/internal/cloudcp/portal/frontend/src/styles.css b/internal/cloudcp/portal/frontend/src/styles.css index f094b0036f..7fdec4f16f 100644 --- a/internal/cloudcp/portal/frontend/src/styles.css +++ b/internal/cloudcp/portal/frontend/src/styles.css @@ -17,6 +17,13 @@ .account-subsection-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin: 0 0 14px; } .account-subsection-header h3 { margin: 0; font-size: 15px; font-weight: 700; } .account-subsection-header p { margin: 0; font-size: 13px; color: #64748b; max-width: 520px; } + .account-status-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 0 0 16px; } + .account-stat-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 14px 16px; display: flex; flex-direction: column; gap: 6px; box-shadow: 0 1px 3px rgba(15,23,42,.04); } + .account-stat-label { font-size: 12px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: #64748b; } + .account-stat-value { font-size: 24px; font-weight: 700; color: #0f172a; } + .account-stat-healthy { color: #166534; } + .account-stat-checking { color: #1d4ed8; } + .account-stat-unhealthy { color: #b91c1c; } .badge { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; text-transform: uppercase; letter-spacing: 0.5px; } .badge-msp { background: #dbeafe; color: #1e40af; } .badge-cloud { background: #dcfce7; color: #166534; } @@ -33,9 +40,11 @@ .workspace-list { display: flex; flex-direction: column; gap: 10px; } .workspace-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 10px; padding: 14px 18px; display: flex; align-items: center; justify-content: space-between; gap: 12px; box-shadow: 0 1px 3px rgba(15,23,42,.04); } .workspace-card:hover { border-color: #cbd5e1; } - .ws-info { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } + .ws-info { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: flex-start; gap: 8px; } + .ws-title-row { display: flex; align-items: center; gap: 10px; width: 100%; } .ws-name { font-weight: 600; font-size: 15px; } .ws-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } + .ws-summary { font-size: 13px; color: #475569; line-height: 1.45; } .ws-created { font-size: 11px; color: #94a3b8; } .ws-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .workspace-menu-wrap { position: relative; } @@ -53,7 +62,11 @@ .btn-danger:hover { background: #fef2f2; } .btn-compact { padding: 8px 14px; font-size: 13px; } .link-button { text-decoration: none; } - .account-actions { margin-top: 14px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } + .account-operations-panel { margin: 0 0 18px; display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 16px 18px; background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; box-shadow: 0 1px 3px rgba(15,23,42,.04); } + .account-operations-copy { display: flex; flex-direction: column; gap: 4px; max-width: 540px; } + .account-operations-copy h3 { margin: 0; font-size: 15px; font-weight: 700; } + .account-operations-copy p { margin: 0; font-size: 13px; color: #64748b; line-height: 1.5; } + .account-actions { display: flex; align-items: center; justify-content: flex-end; gap: 12px; flex-wrap: wrap; } .add-workspace-form { margin-top: 12px; display: none; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px; } .add-workspace-form.visible { display: block; } .add-workspace-form label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; } @@ -141,10 +154,14 @@ @media (max-width: 720px) { .account-header { flex-direction: column; } .account-badges { justify-content: flex-start; } + .account-status-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .account-operations-panel { flex-direction: column; } + .account-actions { justify-content: flex-start; } .account-subsection-header { flex-direction: column; align-items: flex-start; } .overview-stats { grid-template-columns: 1fr; } } @media (max-width: 560px) { + .account-status-grid { grid-template-columns: 1fr; } .workspace-card { flex-direction: column; align-items: flex-start; } .ws-actions { align-self: stretch; } .ws-actions form, .ws-actions .btn-primary { width: 100%; text-align: center; }