diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index a39f2174d..f515c8caf 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -172,6 +172,18 @@ to tenant-owned destinations such as agent installation or reporting without turning the tenant exchange into an open redirect or moving API ownership for agent tokens, alerts, or reports into the control plane. +Pulse Account workspace summaries carry setup state as a backend-owned payload +contract. Browser bootstrap and `/api/portal/dashboard` workspace entries may +include `setup_status` with only `ready`, `setup_path`, `install_agents`, +`configure_outputs`, or `review`, plus optional setup evidence counts such as +`agent_count`, `alert_route_count`, and `report_schedule_count` when the +control plane has a canonical source for them. Until those counts are owned by +a backend source, active healthy workspaces must report `setup_path` rather +than pretending onboarding is complete. The browser may present that state as a +setup checklist and deep-link into tenant install/reporting surfaces, but it +must not infer cross-client readiness from health alone or mint tenant-owned +agent, alert, or report configuration from Pulse Account. + 1. `frontend-modern/src/api/agentProfiles.ts` shared with `agent-lifecycle`: the agent profiles frontend client is both an agent lifecycle control surface and a canonical API payload contract boundary. 2. `frontend-modern/src/api/ai.ts` shared with `ai-runtime`: the AI frontend client is both an AI runtime control surface and a canonical API payload contract boundary. 3. `frontend-modern/src/api/nodes.ts` shared with `agent-lifecycle`: the shared Proxmox node client is both an agent lifecycle setup/install control surface and a canonical API payload contract boundary. diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index c1e07bf77..55da7ff05 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -243,7 +243,11 @@ or other self-hosted uncapped continuity plans. the MSP runs the Pulse instance in their own cloud or infrastructure while Pulse Account owns licensing, account access, and client-workspace management. The canonical provider workspace limits are 5 client workspaces - for Starter, 15 for Growth, and 40 for Scale; larger providers belong to + for Starter, 15 for Growth, and 40 for Scale; the canonical public monthly + prices are $149, $249, and $399 respectively. Public copy may publish all + three prices, but must keep Starter as the only self-serve checkout path and + describe Growth and Scale as request-assisted access rather than implying + immediate self-serve provisioning. Larger providers belong to Enterprise/custom terms. Pulse-hosted MSP is not part of the default public launch motion and must remain request-only until tenant isolation, ingest, reporting, operations, and support load are proven. Design-partner discounts diff --git a/internal/cloudcp/portal/bootstrap.go b/internal/cloudcp/portal/bootstrap.go index 67da9235f..44737e94c 100644 --- a/internal/cloudcp/portal/bootstrap.go +++ b/internal/cloudcp/portal/bootstrap.go @@ -12,6 +12,7 @@ type BootstrapWorkspace struct { State string `json:"state"` Healthy bool `json:"healthy"` HealthStatus string `json:"health_status"` + SetupStatus string `json:"setup_status,omitempty"` LastHealthCheck string `json:"last_health_check,omitempty"` CreatedAt string `json:"created_at"` } @@ -78,6 +79,7 @@ func BuildBootstrapDataWithSignupPath(authenticated bool, email string, accounts State: workspace.State, Healthy: workspace.Healthy, HealthStatus: workspace.HealthStatus, + SetupStatus: workspace.SetupStatus, LastHealthCheck: lastHealthCheck, CreatedAt: workspace.CreatedAt.UTC().Format(time.RFC3339), }) diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index ec7e6e58e..182b520a7 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "43681a0db38909097b68e0cac9790c9ed1bba5137af508c7a765ec21ec38cbbc", + "source_hash": "2c71703848e7ca663ac54560164ea200ed01d13f2755242372eb9bc310bf0f66", "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 f70da9e76..d84bdb1b8 100644 --- a/internal/cloudcp/portal/dist/portal_app.css +++ b/internal/cloudcp/portal/dist/portal_app.css @@ -343,7 +343,7 @@ header .logout-btn:hover, } .workspace-list-head { display: grid; - grid-template-columns: minmax(0, 2fr) 80px 80px 160px; + grid-template-columns: minmax(12rem, 1fr) 112px 88px minmax(300px, auto); gap: 8px; padding: 6px 16px; background: var(--bg-subtle); @@ -358,7 +358,7 @@ header .logout-btn:hover, } .workspace-row { display: grid; - grid-template-columns: minmax(0, 2fr) 80px 80px 160px; + grid-template-columns: minmax(12rem, 1fr) 112px 88px minmax(300px, auto); gap: 8px; align-items: center; padding: 10px 16px; @@ -418,12 +418,17 @@ header .logout-btn:hover, .workspace-actions { display: flex; align-items: center; + flex-wrap: wrap; gap: 6px; justify-content: flex-end; } .workspace-actions form { + flex: 0 0 auto; margin: 0; } +.workspace-actions button { + white-space: nowrap; +} .badge { display: inline-flex; align-items: center; @@ -468,6 +473,27 @@ header .logout-btn:hover, background: var(--danger-subtle); color: var(--danger); } +.badge-setup-ready { + border-color: #aedcb5; + background: var(--success-subtle); + color: var(--success); +} +.badge-setup-setup_path { + border-color: #c6d9f0; + background: var(--accent-subtle); + color: var(--accent); +} +.badge-setup-install_agents, +.badge-setup-configure_outputs { + border-color: #e3c97a; + background: var(--warn-subtle); + color: var(--warn); +} +.badge-setup-review { + border-color: #f5b3b3; + background: var(--danger-subtle); + color: var(--danger); +} .workspace-operations-shell { display: flex; flex-direction: column; @@ -551,6 +577,68 @@ header .logout-btn:hover, font-size: 13px; color: var(--ink-secondary); } +.workspace-setup-checklist { + display: grid; + gap: 8px; +} +.workspace-setup-step { + display: grid; + grid-template-columns: 78px minmax(0, 1fr); + gap: 10px; + align-items: start; + padding: 10px 12px; + border: 1px solid var(--border-muted); + border-radius: var(--radius); + background: var(--bg-subtle); +} +.workspace-setup-step strong, +.workspace-setup-step span { + display: block; +} +.workspace-setup-step strong { + color: var(--ink); + font-size: 13px; +} +.workspace-setup-step div > span { + margin-top: 2px; + color: var(--ink-secondary); + font-size: 12px; +} +.workspace-setup-status { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 22px; + padding: 2px 7px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg); + color: var(--ink-secondary); + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} +.workspace-setup-status-done, +.workspace-setup-status-available { + border-color: #aedcb5; + background: var(--success-subtle); + color: var(--success); +} +.workspace-setup-status-next { + border-color: #e3c97a; + background: var(--warn-subtle); + color: var(--warn); +} +.workspace-setup-status-blocked { + border-color: #f5b3b3; + background: var(--danger-subtle); + color: var(--danger); +} +.workspace-setup-status-pending { + border-color: #c6d9f0; + background: var(--accent-subtle); + color: var(--accent); +} .workspace-management-next-steps { display: flex; flex-direction: column; @@ -1675,6 +1763,9 @@ header .logout-btn:hover, grid-template-columns: 1fr; gap: 8px; } + .workspace-list-head { + display: none; + } .workspace-actions { justify-content: flex-start; width: 100%; @@ -1703,6 +1794,9 @@ header .logout-btn:hover, .workspace-management-facts { grid-template-columns: 1fr; } + .workspace-setup-step { + grid-template-columns: 1fr; + } .workspace-next-step { grid-template-columns: 1fr; } diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index 7d76efd2a..a1e22a4b1 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -66,15 +66,84 @@ var state = String(workspace.state || ""); if (state === "suspended") return "Suspended"; if (state === "failed") return "Failed"; - if (status === "healthy") return "Ready"; + if (status === "checking") return "Health check pending"; if (status === "unhealthy") return "Unhealthy"; - return "Health check pending"; + var setup = workspaceSetupState(workspace); + if (setup === "ready") return "Ready"; + if (setup === "install_agents") return "Install first agent"; + if (setup === "configure_outputs") return "Configure alerts and reports"; + if (setup === "setup_path") return "Setup path ready"; + if (setup === "review") return "Review"; + return "Ready"; + } + function hasNumber(value) { + return typeof value === "number" && Number.isFinite(value); + } + function positiveCount(value) { + return hasNumber(value) && Number(value) > 0; + } + function explicitSetupStatus(workspace) { + var value = String(workspace.setup_status || ""); + if (value === "ready" || value === "setup_path" || value === "install_agents" || value === "configure_outputs" || value === "review") { + return value; + } + return ""; + } + function workspaceSetupState(workspace) { + var explicit = explicitSetupStatus(workspace); + if (explicit) return explicit; + var state = String(workspace.state || ""); + var health = workspaceHealthState(workspace); + if (state === "suspended" || state === "failed" || health === "unhealthy") return "review"; + if (state !== "active" || health === "checking") return "setup_path"; + var knowsAgentCount = hasNumber(workspace.agent_count); + var knowsAlertCount = hasNumber(workspace.alert_route_count); + var knowsReportCount = hasNumber(workspace.report_schedule_count); + var hasAgents = positiveCount(workspace.agent_count); + var hasAlerts = positiveCount(workspace.alert_route_count); + var hasReports = positiveCount(workspace.report_schedule_count); + if (knowsAgentCount && !hasAgents) return "install_agents"; + if (hasAgents && (knowsAlertCount && !hasAlerts || knowsReportCount && !hasReports)) { + return "configure_outputs"; + } + if (hasAgents && (!knowsAlertCount || hasAlerts) && (!knowsReportCount || hasReports)) { + return "ready"; + } + return "setup_path"; + } + function workspaceSetupLabel(workspace) { + switch (workspaceSetupState(workspace)) { + case "ready": + return "Ready"; + case "install_agents": + return "Install agent"; + case "configure_outputs": + return "Configure outputs"; + case "review": + return "Review"; + default: + return "Setup path"; + } + } + function workspaceSetupNextStep(workspace) { + switch (workspaceSetupState(workspace)) { + case "ready": + return "Open the workspace when you need to work inside this client boundary."; + case "install_agents": + return "Install the first agent from this workspace so client data lands in the right boundary."; + case "configure_outputs": + return "Configure alert routing and reports before treating the client workspace as ready."; + case "review": + return "Review the workspace state before continuing setup."; + default: + return "Open the workspace or install agents from the workspace-bound setup path."; + } } function workspaceGuidanceCopy(workspace) { var status = workspaceHealthState(workspace); var state = String(workspace.state || ""); if (state === "active" && status === "healthy") { - return "This workspace is active. Open it to work inside this client boundary, or install agents to reach the workspace-bound install commands."; + return workspaceSetupNextStep(workspace); } if (state === "active" && status === "checking") { return "This workspace is active. The latest health check is still pending, but the workspace can still own agent install commands."; @@ -83,9 +152,9 @@ return "The latest health check is unhealthy. Review the current state before suspending or deleting this workspace."; } if (state === "suspended") { - return "This workspace is suspended. The remaining lifecycle action here is deletion."; + return "This workspace is suspended. The remaining destructive action here is deletion."; } - return "Review the current lifecycle state before taking action on this workspace."; + return "Review the current workspace state before taking action on this workspace."; } // src/account_view.ts @@ -119,6 +188,11 @@ } return parts.join(" \xB7 "); } + function setChecklistStatus(element, tone, label) { + if (!element) return; + element.textContent = label; + element.className = "workspace-setup-status workspace-setup-status-" + tone; + } function findWorkspace(account, workspaceID) { for (var i = 0; i < account.workspaces.length; i += 1) { if (account.workspaces[i].id === workspaceID) return account.workspaces[i]; @@ -153,9 +227,14 @@ var meta = getElement("workspace-management-meta-" + account.id); var summary = getElement("workspace-management-summary-" + account.id); var health = getElement("workspace-management-health-" + account.id); - var lifecycle = getElement("workspace-management-lifecycle-" + account.id); + var setup = getElement("workspace-management-setup-" + account.id); var created = getElement("workspace-management-created-" + account.id); var guidance = getElement("workspace-management-guidance-" + account.id); + var checkCreated = getElement("workspace-management-check-created-" + account.id); + var checkOpen = getElement("workspace-management-check-open-" + account.id); + var checkInstall = getElement("workspace-management-check-install-" + account.id); + var checkOutputs = getElement("workspace-management-check-outputs-" + account.id); + var checkAccess = getElement("workspace-management-check-access-" + account.id); var actionButton = getElement("workspace-management-action-" + account.id); var closeButton = getElement("workspace-management-close-" + account.id); var openForm = getElement("workspace-management-open-form-" + account.id); @@ -164,7 +243,7 @@ var installButton = getElement("workspace-management-install-" + account.id); var reportingForm = getElement("workspace-management-reporting-form-" + account.id); var reportingButton = getElement("workspace-management-reporting-" + account.id); - if (!empty || !content || !title || !meta || !summary || !health || !lifecycle || !created || !guidance || !actionButton || !closeButton) return; + if (!empty || !content || !title || !meta || !summary || !health || !setup || !created || !guidance || !actionButton || !closeButton) return; var workspace = entry.selectedWorkspaceID ? findWorkspace(account, entry.selectedWorkspaceID) : null; var hasSelection = !!workspace; var showDetail = hasSelection || entry.addWorkspaceOpen; @@ -205,9 +284,30 @@ meta.textContent = workspaceMeta(workspace); summary.textContent = workspaceStatusCopy(workspace); health.textContent = workspaceHealthLabel(workspace); - lifecycle.textContent = workspace.state ? workspace.state.charAt(0).toUpperCase() + workspace.state.slice(1) : "Unknown"; + setup.textContent = workspaceSetupLabel(workspace); created.textContent = workspaceCreatedLabel(workspace); guidance.textContent = workspaceGuidanceCopy(workspace); + var setupState = workspaceSetupState(workspace); + var isActive = workspace.state === "active"; + setChecklistStatus(checkCreated, workspace.state ? "done" : "pending", workspace.state ? "Done" : "Pending"); + setChecklistStatus(checkOpen, isActive ? "available" : "blocked", isActive ? "Available" : "Blocked"); + if (setupState === "ready" || setupState === "configure_outputs") { + setChecklistStatus(checkInstall, "done", "Done"); + } else if (setupState === "install_agents" || setupState === "setup_path") { + setChecklistStatus(checkInstall, isActive ? "next" : "blocked", isActive ? "Next" : "Blocked"); + } else { + setChecklistStatus(checkInstall, "blocked", "Review"); + } + if (setupState === "ready") { + setChecklistStatus(checkOutputs, "done", "Done"); + } else if (setupState === "configure_outputs") { + setChecklistStatus(checkOutputs, "next", "Next"); + } else if (setupState === "review") { + setChecklistStatus(checkOutputs, "blocked", "Review"); + } else { + setChecklistStatus(checkOutputs, "pending", "Pending"); + } + setChecklistStatus(checkAccess, "available", "Available"); actionButton.textContent = workspaceActionLabel(workspace); actionButton.disabled = entry.manageWorkspace.pending; actionButton.setAttribute("data-workspace-id", workspace.id); @@ -2280,6 +2380,9 @@ function suspendedWorkspaceChipLabel(count) { return count === 1 ? "1 suspended workspace" : String(count) + " suspended workspaces"; } + function setupNeededWorkspaceChipLabel(count) { + return count === 1 ? "1 workspace in setup" : String(count) + " workspaces in setup"; + } function supportRunbookPathCopy(hasHostedAccounts2, hostedViewOnly, showSelfHostedCommercial) { if (!hasHostedAccounts2) return "Billing, licenses, refunds, or privacy."; if (hostedViewOnly) { @@ -2378,6 +2481,10 @@ } return '' + escapeHTML(workspaceHealthLabel(workspace)) + ""; } + function setupBadgeHTML(workspace) { + var setup = workspaceSetupState(workspace); + return '' + escapeHTML(workspaceSetupLabel(workspace)) + ""; + } function renderBillingActionRow(id, title, actionLabel, description, panelID, focusID, highlights) { var meta = escapeHTML(highlights.join(" \u2022 ")); return '

' + title + "

" + description + '

' + meta + '
"; @@ -2452,6 +2559,7 @@ return "workspace-row-" + accountID + "-" + workspaceID; } var WORKSPACE_INSTALL_TARGET_PATH2 = "/settings/infrastructure?add=linux-host"; + var WORKSPACE_REPORTING_TARGET_PATH2 = "/settings/support/reporting"; function workspaceHandoffActionPath2(accountAPIBasePath, accountID, workspaceID, targetPath = "") { var path = accountAPIBasePath + "/" + encodeURIComponent(accountID) + "/tenants/" + encodeURIComponent(workspaceID) + "/handoff"; if (!targetPath) return path; @@ -2462,6 +2570,9 @@ var state = String(workspace.state || ""); var createdLabel = formatWorkspaceDate(workspace.created_at); var metaParts = []; + if (state) { + metaParts.push('' + escapeHTML(titleCase(state)) + ""); + } if (createdLabel) { metaParts.push('Created ' + escapeHTML(createdLabel) + ""); } @@ -2478,9 +2589,9 @@ } var manageAction = ""; if (account.can_manage && (state === "active" || state === "suspended" || state === "failed")) { - manageAction = ''; + manageAction = ''; } - return '

' + escapeHTML(workspace.display_name) + '

' + metaParts.join("") + '
' + escapeHTML(workspaceRowNote(workspace)) + '
' + healthBadgeHTML(workspace) + '
' + escapeHTML(titleCase(state || "Unknown")) + '
' + openAction + installAction + manageAction + "
"; + return '

' + escapeHTML(workspace.display_name) + '

' + metaParts.join("") + '
' + escapeHTML(workspaceRowNote(workspace)) + '
' + setupBadgeHTML(workspace) + '
' + healthBadgeHTML(workspace) + '
' + openAction + installAction + manageAction + "
"; } function renderWorkspaceHandoffForm(accountID, workspaceID, accountAPIBasePath, label, buttonClassName = "btn-secondary btn-compact") { if (!accountAPIBasePath) { @@ -2494,6 +2605,12 @@ } return '
"; } + function renderWorkspaceReportingHandoffForm(accountID, workspaceID, accountAPIBasePath, label = "Open reports", buttonClassName = "btn-secondary btn-compact") { + if (!accountAPIBasePath) { + return '"; + } + return '
"; + } function attentionWorkspaceEntries(entries) { var results = []; for (var i = 0; i < entries.length; i += 1) { @@ -2507,7 +2624,7 @@ function readyWorkspaceEntries(entries) { var results = []; for (var i = 0; i < entries.length; i += 1) { - if (String(entries[i].workspace.state || "") === "active" && workspaceHealthState(entries[i].workspace) === "healthy") { + if (String(entries[i].workspace.state || "") === "active" && workspaceSetupState(entries[i].workspace) === "ready") { results.push(entries[i]); } } @@ -2522,6 +2639,18 @@ } return results; } + function setupNeededWorkspaceEntries(entries) { + var results = []; + for (var i = 0; i < entries.length; i += 1) { + if (String(entries[i].workspace.state || "") !== "active") continue; + if (workspaceHealthState(entries[i].workspace) !== "healthy") continue; + var setup = workspaceSetupState(entries[i].workspace); + if (setup === "install_agents" || setup === "configure_outputs" || setup === "setup_path") { + results.push(entries[i]); + } + } + return results; + } function workspaceSummaryContext(entry, includeAccountName, note) { if (!includeAccountName) return note; return entry.account.name + " \xB7 " + note; @@ -2532,6 +2661,7 @@ function renderWorkspaceSummaryDecision(accounts, entries, accountAPIBasePath, showSelfHostedCommercial) { var attention = attentionWorkspaceEntries(entries); var suspended = suspendedWorkspaceEntries(entries); + var setupNeeded = setupNeededWorkspaceEntries(entries); var ready = readyWorkspaceEntries(entries); var primaryAction = ""; var secondaryAction = ""; @@ -2554,7 +2684,7 @@ "Review workspace", "btn-primary btn-compact workspace-summary-link" ); - secondaryAction = attentionEntry.account.can_manage && (attentionEntry.workspace.state === "active" || attentionEntry.workspace.state === "suspended" || attentionEntry.workspace.state === "failed") ? '' : ''; + secondaryAction = attentionEntry.account.can_manage && (attentionEntry.workspace.state === "active" || attentionEntry.workspace.state === "suspended" || attentionEntry.workspace.state === "failed") ? '' : ''; } else if (suspended.length) { var suspendedEntry = suspended[0]; title = "Review " + suspendedEntry.workspace.display_name; @@ -2564,7 +2694,26 @@ "Review workspace", "btn-primary btn-compact workspace-summary-link" ); - secondaryAction = suspendedEntry.account.can_manage ? '' : ''; + secondaryAction = suspendedEntry.account.can_manage ? '' : ''; + } else if (setupNeeded.length) { + var setupEntry = setupNeeded[0]; + var setupState = workspaceSetupState(setupEntry.workspace); + title = setupState === "configure_outputs" ? "Configure outputs for " + setupEntry.workspace.display_name : "Set up " + setupEntry.workspace.display_name; + description = workspaceSummaryContext(setupEntry, accounts.length > 1, workspaceSetupNextStep(setupEntry.workspace)); + primaryAction = setupState === "configure_outputs" ? renderWorkspaceReportingHandoffForm( + setupEntry.account.id, + setupEntry.workspace.id, + accountAPIBasePath, + "Open reports", + "btn-primary btn-compact" + ) : renderWorkspaceInstallHandoffForm( + setupEntry.account.id, + setupEntry.workspace.id, + accountAPIBasePath, + "Install agents", + "btn-primary btn-compact" + ); + secondaryAction = setupEntry.account.can_manage ? '' : renderWorkspaceHandoffForm(setupEntry.account.id, setupEntry.workspace.id, accountAPIBasePath, "Open workspace"); } else if (ready.length) { var readyEntry = ready[0]; title = "Open " + readyEntry.workspace.display_name; @@ -2617,6 +2766,7 @@ accountCountLabel(accounts.length), workspaceCountLabel(entries.length), readyWorkspaceChipLabel(readyWorkspaceEntries(entries).length), + setupNeededWorkspaceChipLabel(setupNeededWorkspaceEntries(entries).length), reviewWorkspaceChipLabel(attentionWorkspaceEntries(entries).length), suspendedWorkspaceChipLabel(suspendedWorkspaceEntries(entries).length) ]; @@ -2633,9 +2783,9 @@ return canManageAnyWorkspace ? "Review hosted workspaces here, then create the next workspace when you are ready." : "Review hosted workspace state here. An owner or admin must create or change hosted workspaces."; } if (!canManageAnyWorkspace) { - return "Review hosted workspace health here and open ready workspaces. An owner or admin must handle lifecycle changes."; + return "Review hosted workspace health here and open ready workspaces. An owner or admin must handle setup and workspace changes."; } - return "Review hosted workspace health here, open the next ready workspace, and use Lifecycle only when an account-level change is required."; + return "Review client workspaces here, use the setup checklist for onboarding, and keep destructive workspace actions separate from daily workspace work."; } function renderWorkspaceSummarySection(context) { var accounts = Array.isArray(context.bootstrap.accounts) ? context.bootstrap.accounts : []; @@ -2654,7 +2804,7 @@ var readyCount = countReadyWorkspaces(workspaces); var attentionCount = attentionWorkspaces(workspaces).length; var suspendedCount = countWorkspacesByState(workspaces, "suspended"); - var workspaceListSummary = account.can_manage ? "Open a workspace to work in it. Use Lifecycle only when you need account-level changes." : "Open a workspace here. An owner or admin must create or change hosted workspaces."; + var workspaceListSummary = account.can_manage ? "Open a workspace to work in it, or use Setup checklist when onboarding a client." : "Open a workspace here. An owner or admin must create or change hosted workspaces."; var workspaceManagement = ""; var addWorkspaceForm = ""; var workspaceHeaderActions = ""; @@ -2665,9 +2815,9 @@ if (account.kind === "msp") { addWorkspaceForm = '
'; } - workspaceManagement = ''; + workspaceManagement = ''; } - var workspaceHTML = workspaces.length ? '
' + (workspaceHeaderActions ? '
' + workspaceHeaderActions + "
" : "") + '
WorkspaceHealthLifecycleActions
' + workspaces.map(function(workspace) { + var workspaceHTML = workspaces.length ? '
' + (workspaceHeaderActions ? '
' + workspaceHeaderActions + "
" : "") + '
WorkspaceSetupHealthActions
' + workspaces.map(function(workspace) { return renderWorkspaceCard(account, workspace, accountAPIBasePath); }).join("") + "
" : '

' + escapeHTML(account.can_manage ? "No hosted workspaces yet. Create one to get started." : "No hosted workspaces are attached yet. An owner or admin must create the first one.") + "

" + (workspaceHeaderActions ? '
' + workspaceHeaderActions + "
" : "") + "
"; return '"; @@ -2786,7 +2936,7 @@ statusHTML = '
' + escapeHTML(successMessage) + '

Need another link? Send it again.
'; } var signupHTML = hasSignupPath(context.signupPath) ? '

Need a new Pulse Account? Create an account.

' : ""; - return '

Sign in to Pulse Account

Use one commercial email address for hosted workspaces, account access, billing, licenses, refunds, and privacy requests.

' + renderAuthScopeRow("Workspaces", "Open hosted workspaces and review lifecycle state.") + renderAuthScopeRow("Access", "Review account access and manage roles when permitted.") + renderAuthScopeRow("Billing", "Open hosted billing or self-hosted commercial tools when they apply.") + '

Email sign-in link

Enter the commercial email address for your Pulse account. A sign-in link will be sent to that address.

" + signupHTML + statusHTML + "
"; + return '

Sign in to Pulse Account

Use one commercial email address for hosted workspaces, account access, billing, licenses, refunds, and privacy requests.

' + renderAuthScopeRow("Workspaces", "Open hosted workspaces and review workspace state.") + renderAuthScopeRow("Access", "Review account access and manage roles when permitted.") + renderAuthScopeRow("Billing", "Open hosted billing or self-hosted commercial tools when they apply.") + '

Email sign-in link

Enter the commercial email address for your Pulse account. A sign-in link will be sent to that address.

" + signupHTML + statusHTML + "
"; } // src/shell.ts diff --git a/internal/cloudcp/portal/frontend/dev.mjs b/internal/cloudcp/portal/frontend/dev.mjs index a8bb7827c..4d252bdaa 100644 --- a/internal/cloudcp/portal/frontend/dev.mjs +++ b/internal/cloudcp/portal/frontend/dev.mjs @@ -57,6 +57,10 @@ function buildScenarioTemplate(name) { state: 'active', healthy: true, health_status: 'healthy', + setup_status: 'ready', + agent_count: 2, + alert_route_count: 1, + report_schedule_count: 1, created_at: iso('2026-03-20T10:00:00Z'), }, { @@ -65,6 +69,10 @@ function buildScenarioTemplate(name) { state: 'active', healthy: true, health_status: 'healthy', + setup_status: 'ready', + agent_count: 1, + alert_route_count: 1, + report_schedule_count: 1, created_at: iso('2026-03-21T10:00:00Z'), }, ], @@ -122,6 +130,7 @@ function buildScenarioTemplate(name) { state: 'suspended', healthy: false, health_status: 'unhealthy', + setup_status: 'review', created_at: iso('2026-03-20T10:00:00Z'), }, { @@ -130,6 +139,10 @@ function buildScenarioTemplate(name) { state: 'active', healthy: true, health_status: 'healthy', + setup_status: 'configure_outputs', + agent_count: 1, + alert_route_count: 0, + report_schedule_count: 0, created_at: iso('2026-03-21T10:00:00Z'), }, ], @@ -233,7 +246,9 @@ function escapeHTML(value) { return String(value || '') .replace(/&/g, '&') .replace(//g, '>'); + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); } function previewReturnURL(request, scenario, toastMessage) { @@ -281,8 +296,23 @@ function buildPreviewWorkspaceHTML(bootstrap, workspaceID, targetPath, scenario) const entry = findWorkspaceByID(bootstrap, workspaceID); const title = entry ? entry.workspace.display_name : workspaceID; const accountName = entry ? entry.account.name : 'Pulse Account'; + const accountID = entry ? entry.account.id : ''; const targetLabel = previewTargetLabel(targetPath); const portalURL = '/?scenario=' + encodeURIComponent(scenario); + const portalWorkspaceURL = accountID + ? portalURL + '#workspace-row-' + encodeURIComponent(accountID) + '-' + encodeURIComponent(workspaceID) + : portalURL; + const targetPathLabel = targetPath || '/'; + const targetHeading = targetPath === '/settings/infrastructure?add=linux-host' + ? 'Install agents for ' + title + : targetPath === '/settings/support/reporting' + ? 'Reports for ' + title + : title + ' workspace'; + const taskCopy = targetPath === '/settings/infrastructure?add=linux-host' + ? 'Use the install command from this workspace. Agent data must be created inside this client boundary, not on the provider account.' + : targetPath === '/settings/support/reporting' + ? 'Configure scheduled reports and alert routing for this client workspace before treating onboarding as complete.' + : 'Use this workspace for client-specific monitoring work after the Pulse Account handoff.'; return '' + '' + '' + @@ -291,31 +321,48 @@ function buildPreviewWorkspaceHTML(bootstrap, workspaceID, targetPath, scenario) '' + escapeHTML(title) + ' - Preview workspace' + '' + '' + '' + '
' + '
' + + '
Pulse Account/' + escapeHTML(accountName) + '/' + escapeHTML(title) + '
' + '

Preview workspace handoff

' + - '

' + escapeHTML(title) + '

' + - '

This preview page stands in for the hosted client workspace. In production the signed handoff creates a session inside the client workspace and opens ' + escapeHTML(targetLabel) + '.

' + + '

' + escapeHTML(targetHeading) + '

' + + '

You are inside the ' + escapeHTML(title) + ' client workspace. In production the signed handoff creates a tenant session and opens ' + escapeHTML(targetLabel) + '.

' + '
' + '
Account' + escapeHTML(accountName) + '
' + '
Workspace' + escapeHTML(workspaceID) + '
' + - '
Target' + escapeHTML(targetPath || '/') + '
' + + '
Target' + escapeHTML(targetPathLabel) + '
' + + '
' + + '
' + + '' + escapeHTML(targetLabel) + '' + + '

' + escapeHTML(taskCopy) + '

' + + (targetPath === '/settings/infrastructure?add=linux-host' + ? 'pulse-agent install --workspace ' + escapeHTML(workspaceID) + ' --name <agent-name>' + : '') + + '
' + + '' + - '

Agents, alerts, and reports are scoped to this workspace after the handoff.

' + - 'Back to Pulse Account' + '
' + '
' + '' + @@ -540,6 +587,10 @@ function routeAccountAPI(request, response, url, bootstrap, scenario) { state: 'active', healthy: true, health_status: 'healthy', + setup_status: 'install_agents', + agent_count: 0, + alert_route_count: 0, + report_schedule_count: 0, created_at: iso(new Date()), }); sendJSON(response, 200, { ok: true }); diff --git a/internal/cloudcp/portal/frontend/src/account_runtime.test.ts b/internal/cloudcp/portal/frontend/src/account_runtime.test.ts index 6c2aa59c7..bb256dcf9 100644 --- a/internal/cloudcp/portal/frontend/src/account_runtime.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_runtime.test.ts @@ -82,7 +82,7 @@ describe('account runtime', function() { '
' + '' + '' + - '
' + + '
' + '
'; runtime.toggleAddWorkspace('acct_1'); @@ -144,7 +144,7 @@ describe('account runtime', function() { '

' + '

' + '
' + - '
' + + '
' + '
' + '
' + '' + @@ -193,7 +193,7 @@ describe('account runtime', function() { expect(deps.store.getAccountState().byAccountID.acct_1.selectedWorkspaceID).toBe(''); }); - it('reveals the lifecycle panel when a workspace job opens below the viewport', function() { + it('reveals the setup panel when a workspace job opens below the viewport', function() { deps.store.setBootstrap({ authenticated: true, email: 'owner@example.com', @@ -227,7 +227,7 @@ describe('account runtime', function() { '

' + '

' + '
' + - '
' + + '
' + '
' + '
' + '' + @@ -287,7 +287,7 @@ describe('account runtime', function() { '

' + '

' + '
' + - '
' + + '
' + '
' + '
' + '' + @@ -413,7 +413,7 @@ describe('account runtime', function() { ); document.body.innerHTML = - '
' + + '
' + '
' + '
' + '
' + diff --git a/internal/cloudcp/portal/frontend/src/account_view.test.ts b/internal/cloudcp/portal/frontend/src/account_view.test.ts index 340ab8398..af014f20b 100644 --- a/internal/cloudcp/portal/frontend/src/account_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_view.test.ts @@ -74,9 +74,14 @@ describe('account view', function() { '

' + '

' + '
' + - '
' + + '
' + '
' + '
' + + '' + + '' + + '' + + '' + + '' + '
' + '
' + '
' + @@ -298,6 +303,10 @@ describe('account view', function() { 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-health-acct_1')?.textContent).toContain('Healthy'); + expect(document.getElementById('workspace-management-setup-acct_1')?.textContent).toContain('Setup path'); + expect(document.getElementById('workspace-management-check-install-acct_1')?.textContent).toContain('Next'); + expect(document.getElementById('workspace-management-check-outputs-acct_1')?.textContent).toContain('Pending'); + expect(document.getElementById('workspace-management-check-access-acct_1')?.textContent).toContain('Available'); expect(document.getElementById('workspace-management-action-acct_1')?.textContent).toContain('Suspend workspace'); expect((document.getElementById('workspace-management-open-form-acct_1') as HTMLFormElement).action).toContain('/api/accounts/acct_1/tenants/ws_1/handoff'); expect((document.getElementById('workspace-management-install-form-acct_1') as HTMLFormElement).action).toContain('/api/accounts/acct_1/tenants/ws_1/handoff?target_path=%2Fsettings%2Finfrastructure%3Fadd%3Dlinux-host'); diff --git a/internal/cloudcp/portal/frontend/src/account_view.ts b/internal/cloudcp/portal/frontend/src/account_view.ts index 17ae0de19..c298802a0 100644 --- a/internal/cloudcp/portal/frontend/src/account_view.ts +++ b/internal/cloudcp/portal/frontend/src/account_view.ts @@ -7,7 +7,13 @@ import type { PortalWorkspaceSummary, } from './types'; import { normalizePortalRole, portalRoleCapabilityCopy, portalRoleLabel } from './account_roles'; -import { workspaceGuidanceCopy, workspaceHealthLabel, workspaceStatusCopy } from './workspace_presentation'; +import { + workspaceGuidanceCopy, + workspaceHealthLabel, + workspaceSetupLabel, + workspaceSetupState, + workspaceStatusCopy, +} from './workspace_presentation'; type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; @@ -47,6 +53,12 @@ function workspaceMeta(workspace: PortalWorkspaceSummary): string { return parts.join(' · '); } +function setChecklistStatus(element: HTMLElement | null, tone: string, label: string): void { + if (!element) return; + element.textContent = label; + element.className = 'workspace-setup-status workspace-setup-status-' + tone; +} + 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]; @@ -93,9 +105,14 @@ export function renderWorkspaceManagement(account: PortalAccountSummary, entry: var meta = getElement('workspace-management-meta-' + account.id); var summary = getElement('workspace-management-summary-' + account.id); var health = getElement('workspace-management-health-' + account.id); - var lifecycle = getElement('workspace-management-lifecycle-' + account.id); + var setup = getElement('workspace-management-setup-' + account.id); var created = getElement('workspace-management-created-' + account.id); var guidance = getElement('workspace-management-guidance-' + account.id); + var checkCreated = getElement('workspace-management-check-created-' + account.id); + var checkOpen = getElement('workspace-management-check-open-' + account.id); + var checkInstall = getElement('workspace-management-check-install-' + account.id); + var checkOutputs = getElement('workspace-management-check-outputs-' + account.id); + var checkAccess = getElement('workspace-management-check-access-' + account.id); var actionButton = getElement('workspace-management-action-' + account.id); var closeButton = getElement('workspace-management-close-' + account.id); var openForm = getElement('workspace-management-open-form-' + account.id); @@ -104,7 +121,7 @@ export function renderWorkspaceManagement(account: PortalAccountSummary, entry: var installButton = getElement('workspace-management-install-' + account.id); var reportingForm = getElement('workspace-management-reporting-form-' + account.id); var reportingButton = getElement('workspace-management-reporting-' + account.id); - if (!empty || !content || !title || !meta || !summary || !health || !lifecycle || !created || !guidance || !actionButton || !closeButton) return; + if (!empty || !content || !title || !meta || !summary || !health || !setup || !created || !guidance || !actionButton || !closeButton) return; var workspace = entry.selectedWorkspaceID ? findWorkspace(account, entry.selectedWorkspaceID) : null; var hasSelection = !!workspace; @@ -148,9 +165,30 @@ export function renderWorkspaceManagement(account: PortalAccountSummary, entry: meta.textContent = workspaceMeta(workspace); summary.textContent = workspaceStatusCopy(workspace); health.textContent = workspaceHealthLabel(workspace); - lifecycle.textContent = workspace.state ? workspace.state.charAt(0).toUpperCase() + workspace.state.slice(1) : 'Unknown'; + setup.textContent = workspaceSetupLabel(workspace); created.textContent = workspaceCreatedLabel(workspace); guidance.textContent = workspaceGuidanceCopy(workspace); + var setupState = workspaceSetupState(workspace); + var isActive = workspace.state === 'active'; + setChecklistStatus(checkCreated, workspace.state ? 'done' : 'pending', workspace.state ? 'Done' : 'Pending'); + setChecklistStatus(checkOpen, isActive ? 'available' : 'blocked', isActive ? 'Available' : 'Blocked'); + if (setupState === 'ready' || setupState === 'configure_outputs') { + setChecklistStatus(checkInstall, 'done', 'Done'); + } else if (setupState === 'install_agents' || setupState === 'setup_path') { + setChecklistStatus(checkInstall, isActive ? 'next' : 'blocked', isActive ? 'Next' : 'Blocked'); + } else { + setChecklistStatus(checkInstall, 'blocked', 'Review'); + } + if (setupState === 'ready') { + setChecklistStatus(checkOutputs, 'done', 'Done'); + } else if (setupState === 'configure_outputs') { + setChecklistStatus(checkOutputs, 'next', 'Next'); + } else if (setupState === 'review') { + setChecklistStatus(checkOutputs, 'blocked', 'Review'); + } else { + setChecklistStatus(checkOutputs, 'pending', 'Pending'); + } + setChecklistStatus(checkAccess, 'available', 'Available'); actionButton.textContent = workspaceActionLabel(workspace); actionButton.disabled = entry.manageWorkspace.pending; actionButton.setAttribute('data-workspace-id', workspace.id); diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts index c33532219..177d3afe8 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts @@ -124,7 +124,8 @@ describe('shell view', function() { expect(html).toContain('workspace-summary-shell'); expect(html).toContain('workspace-summary-facts'); - expect(html).toContain('Next: Open Summary Workspace'); + expect(html).toContain('Next: Set up Summary Workspace'); + expect(html).toContain('1 workspace in setup'); expect(html).toContain('Summary Workspace'); expect(html).not.toContain('overview-task-grid'); }); @@ -187,7 +188,8 @@ describe('shell view', function() { expect(html).toContain('workspace-summary-facts'); expect(html).toContain('1 account'); expect(html).toContain('3 workspaces'); - expect(html).toContain('1 ready workspace'); + expect(html).toContain('0 ready workspaces'); + expect(html).toContain('1 workspace in setup'); expect(html).toContain('2 workspaces to review'); expect(html).toContain('Next: Review Beta Workspace'); expect(html).toContain('Create workspace'); @@ -436,7 +438,7 @@ describe('shell view', function() { ); expect(html).toContain('Tech'); - expect(html).toContain('Review hosted workspace health here and open ready workspaces. An owner or admin must handle lifecycle changes.'); + expect(html).toContain('Review hosted workspace health here and open ready workspaces. An owner or admin must handle setup and workspace changes.'); expect(html).toContain('Review who has access. An owner or admin must make changes.'); expect(html).toContain('An owner or admin on this account needs to open hosted billing.'); expect(html).toContain('Try first'); diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts index 1639e081c..f232c01d3 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.ts @@ -8,7 +8,15 @@ import type { } from './types'; import { portalRoleLabel } from './account_roles'; import { preferredPortalShellSection } from './shell_section'; -import { workspaceHealthLabel, workspaceHealthState, workspaceRowNote, workspaceStatusCopy } from './workspace_presentation'; +import { + workspaceHealthLabel, + workspaceHealthState, + workspaceRowNote, + workspaceSetupLabel, + workspaceSetupNextStep, + workspaceSetupState, + workspaceStatusCopy, +} from './workspace_presentation'; export interface ShellViewContext { bootstrap: PortalBootstrapData; @@ -92,6 +100,10 @@ function suspendedWorkspaceChipLabel(count: number): string { return count === 1 ? '1 suspended workspace' : String(count) + ' suspended workspaces'; } +function setupNeededWorkspaceChipLabel(count: number): string { + return count === 1 ? '1 workspace in setup' : String(count) + ' workspaces in setup'; +} + function supportLeadCopy(hasHostedAccounts: boolean, hostedViewOnly: boolean, showSelfHostedCommercial: boolean): string { @@ -250,6 +262,11 @@ function healthBadgeHTML(workspace: PortalWorkspaceSummary): string { return '' + escapeHTML(workspaceHealthLabel(workspace)) + ''; } +function setupBadgeHTML(workspace: PortalWorkspaceSummary): string { + var setup = workspaceSetupState(workspace); + return '' + escapeHTML(workspaceSetupLabel(workspace)) + ''; +} + function renderBillingActionRow( id: string, title: string, @@ -418,6 +435,9 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor var state = String(workspace.state || ''); var createdLabel = formatWorkspaceDate(workspace.created_at); var metaParts = []; + if (state) { + metaParts.push('' + escapeHTML(titleCase(state)) + ''); + } if (createdLabel) { metaParts.push('Created ' + escapeHTML(createdLabel) + ''); } @@ -452,7 +472,7 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor escapeAttr(account.id) + '" data-workspace-id="' + escapeAttr(workspace.id) + - '">Lifecycle'; + '">Setup checklist'; } return ( @@ -465,10 +485,10 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor '
' + escapeHTML(workspaceRowNote(workspace)) + '
' + '
' + '
' + - healthBadgeHTML(workspace) + + setupBadgeHTML(workspace) + '
' + '
' + - '' + escapeHTML(titleCase(state || 'Unknown')) + '' + + healthBadgeHTML(workspace) + '
' + '
' + openAction + @@ -515,6 +535,19 @@ function renderWorkspaceInstallHandoffForm(accountID: string, workspaceID: strin ); } +function renderWorkspaceReportingHandoffForm(accountID: string, workspaceID: string, accountAPIBasePath: string, label = 'Open reports', buttonClassName = 'btn-secondary btn-compact'): string { + if (!accountAPIBasePath) { + return ''; + } + return ( + '
' + + '' + + '
' + ); +} + function attentionWorkspaceEntries(entries: WorkspaceSummaryEntry[]): WorkspaceSummaryEntry[] { var results: WorkspaceSummaryEntry[] = []; for (var i = 0; i < entries.length; i += 1) { @@ -529,7 +562,7 @@ function attentionWorkspaceEntries(entries: WorkspaceSummaryEntry[]): WorkspaceS function readyWorkspaceEntries(entries: WorkspaceSummaryEntry[]): WorkspaceSummaryEntry[] { var results: WorkspaceSummaryEntry[] = []; for (var i = 0; i < entries.length; i += 1) { - if (String(entries[i].workspace.state || '') === 'active' && workspaceHealthState(entries[i].workspace) === 'healthy') { + if (String(entries[i].workspace.state || '') === 'active' && workspaceSetupState(entries[i].workspace) === 'ready') { results.push(entries[i]); } } @@ -546,6 +579,19 @@ function suspendedWorkspaceEntries(entries: WorkspaceSummaryEntry[]): WorkspaceS return results; } +function setupNeededWorkspaceEntries(entries: WorkspaceSummaryEntry[]): WorkspaceSummaryEntry[] { + var results: WorkspaceSummaryEntry[] = []; + for (var i = 0; i < entries.length; i += 1) { + if (String(entries[i].workspace.state || '') !== 'active') continue; + if (workspaceHealthState(entries[i].workspace) !== 'healthy') continue; + var setup = workspaceSetupState(entries[i].workspace); + if (setup === 'install_agents' || setup === 'configure_outputs' || setup === 'setup_path') { + results.push(entries[i]); + } + } + return results; +} + function workspaceSummaryContext(entry: WorkspaceSummaryEntry, includeAccountName: boolean, note: string): string { if (!includeAccountName) return note; return entry.account.name + ' · ' + note; @@ -570,6 +616,7 @@ function renderWorkspaceSummaryDecision( ): WorkspaceSummaryDecision { var attention = attentionWorkspaceEntries(entries); var suspended = suspendedWorkspaceEntries(entries); + var setupNeeded = setupNeededWorkspaceEntries(entries); var ready = readyWorkspaceEntries(entries); var primaryAction = ''; var secondaryAction = ''; @@ -602,7 +649,7 @@ function renderWorkspaceSummaryDecision( escapeAttr(attentionEntry.account.id) + '" data-workspace-id="' + escapeAttr(attentionEntry.workspace.id) + - '">Open lifecycle' + '">Setup checklist' : ''; } else if (suspended.length) { var suspendedEntry = suspended[0]; @@ -618,8 +665,37 @@ function renderWorkspaceSummaryDecision( escapeAttr(suspendedEntry.account.id) + '" data-workspace-id="' + escapeAttr(suspendedEntry.workspace.id) + - '">Open lifecycle' + '">Setup checklist' : ''; + } else if (setupNeeded.length) { + var setupEntry = setupNeeded[0]; + var setupState = workspaceSetupState(setupEntry.workspace); + title = setupState === 'configure_outputs' + ? 'Configure outputs for ' + setupEntry.workspace.display_name + : 'Set up ' + setupEntry.workspace.display_name; + description = workspaceSummaryContext(setupEntry, accounts.length > 1, workspaceSetupNextStep(setupEntry.workspace)); + primaryAction = setupState === 'configure_outputs' + ? renderWorkspaceReportingHandoffForm( + setupEntry.account.id, + setupEntry.workspace.id, + accountAPIBasePath, + 'Open reports', + 'btn-primary btn-compact', + ) + : renderWorkspaceInstallHandoffForm( + setupEntry.account.id, + setupEntry.workspace.id, + accountAPIBasePath, + 'Install agents', + 'btn-primary btn-compact', + ); + secondaryAction = setupEntry.account.can_manage + ? '' + : renderWorkspaceHandoffForm(setupEntry.account.id, setupEntry.workspace.id, accountAPIBasePath, 'Open workspace'); } else if (ready.length) { var readyEntry = ready[0]; title = 'Open ' + readyEntry.workspace.display_name; @@ -686,6 +762,7 @@ function renderWorkspaceSummaryFacts(accounts: PortalAccountSummary[], entries: accountCountLabel(accounts.length), workspaceCountLabel(entries.length), readyWorkspaceChipLabel(readyWorkspaceEntries(entries).length), + setupNeededWorkspaceChipLabel(setupNeededWorkspaceEntries(entries).length), reviewWorkspaceChipLabel(attentionWorkspaceEntries(entries).length), suspendedWorkspaceChipLabel(suspendedWorkspaceEntries(entries).length), ]; @@ -720,9 +797,9 @@ function workspaceSectionHeaderCopy(accounts: PortalAccountSummary[], entries: W : 'Review hosted workspace state here. An owner or admin must create or change hosted workspaces.'; } if (!canManageAnyWorkspace) { - return 'Review hosted workspace health here and open ready workspaces. An owner or admin must handle lifecycle changes.'; + return 'Review hosted workspace health here and open ready workspaces. An owner or admin must handle setup and workspace changes.'; } - return 'Review hosted workspace health here, open the next ready workspace, and use Lifecycle only when an account-level change is required.'; + return 'Review client workspaces here, use the setup checklist for onboarding, and keep destructive workspace actions separate from daily workspace work.'; } export function renderWorkspaceSummarySection(context: ShellViewContext): string { @@ -779,7 +856,7 @@ function renderAccountWorkspaceSection(account: PortalAccountSummary, accountAPI var attentionCount = attentionWorkspaces(workspaces).length; var suspendedCount = countWorkspacesByState(workspaces, 'suspended'); var workspaceListSummary = account.can_manage - ? 'Open a workspace to work in it. Use Lifecycle only when you need account-level changes.' + ? 'Open a workspace to work in it, or use Setup checklist when onboarding a client.' : 'Open a workspace here. An owner or admin must create or change hosted workspaces.'; var workspaceManagement = ''; var addWorkspaceForm = ''; @@ -822,8 +899,8 @@ function renderAccountWorkspaceSection(account: PortalAccountSummary, accountAPI '" hidden>' + '
' + '
' + - '

Workspace lifecycle

' + - '

Manage this workspace or create a new one.

' + + '

Workspace setup checklist

' + + '

Finish the client setup steps without mixing client data across workspaces.

' + '
' + '
' + '
' + - 'Lifecycle' + - '' + + 'Setup' + + '' + '
' + '
' + 'Created' + @@ -874,6 +951,28 @@ function renderAccountWorkspaceSection(account: PortalAccountSummary, accountAPI '
' + + '
' + + '
' + + '' + + '
Workspace createdThis client has a separate account boundary.
' + + '
' + + '
' + + '' + + '
Open the workspaceWork inside the selected client boundary.
' + + '
' + + '
' + + '' + + '
Install the first agentUse the workspace-bound install path so data lands in this client.
' + + '
' + + '
' + + '' + + '
Configure alerts and reportsKeep notifications and reports scoped to this client.
' + + '
' + + '
' + + '' + + '
Review accessInvite provider staff or client users from Access.
' + + '
' + + '
' + '
' + @@ -907,6 +1006,10 @@ function renderAccountWorkspaceSection(account: PortalAccountSummary, accountAPI '">Open reports' + '' + '
' + + '
' + + '
AccessInvite people or adjust roles from the account access boundary.
' + + '' + + '
' + '
' + '
' + '
' : '') + '
' + 'Workspace' + + 'Setup' + 'Health' + - 'Lifecycle' + 'Actions' + '
' + '
' + workspaces.map(function(workspace) { @@ -1380,7 +1483,7 @@ export function renderSignedOutPortalHTML(context: ShellViewContext): string { '

Sign in to Pulse Account

' + '

Use one commercial email address for hosted workspaces, account access, billing, licenses, refunds, and privacy requests.

' + '
' + - renderAuthScopeRow('Workspaces', 'Open hosted workspaces and review lifecycle state.') + + renderAuthScopeRow('Workspaces', 'Open hosted workspaces and review workspace state.') + renderAuthScopeRow('Access', 'Review account access and manage roles when permitted.') + renderAuthScopeRow('Billing', 'Open hosted billing or self-hosted commercial tools when they apply.') + '
' + diff --git a/internal/cloudcp/portal/frontend/src/styles.css b/internal/cloudcp/portal/frontend/src/styles.css index e46bd401c..e1917bfee 100644 --- a/internal/cloudcp/portal/frontend/src/styles.css +++ b/internal/cloudcp/portal/frontend/src/styles.css @@ -321,7 +321,7 @@ header .logout-btn:hover, .workspace-list-head { display: grid; - grid-template-columns: minmax(0, 2fr) 80px 80px 160px; + grid-template-columns: minmax(12rem, 1fr) 112px 88px minmax(300px, auto); gap: 8px; padding: 6px 16px; background: var(--bg-subtle); @@ -338,7 +338,7 @@ header .logout-btn:hover, .workspace-row { display: grid; - grid-template-columns: minmax(0, 2fr) 80px 80px 160px; + grid-template-columns: minmax(12rem, 1fr) 112px 88px minmax(300px, auto); gap: 8px; align-items: center; padding: 10px 16px; @@ -405,11 +405,13 @@ header .logout-btn:hover, .workspace-actions { display: flex; align-items: center; + flex-wrap: wrap; gap: 6px; justify-content: flex-end; } -.workspace-actions form { margin: 0; } +.workspace-actions form { flex: 0 0 auto; margin: 0; } +.workspace-actions button { white-space: nowrap; } /* ── Badges ───────────────────────────────────────────────────── */ .badge { @@ -431,6 +433,11 @@ header .logout-btn:hover, .badge-checking, .badge-provisioning { border-color: #c6d9f0; background: var(--accent-subtle); color: var(--accent); } .badge-suspended { border-color: #e3c97a; background: var(--warn-subtle); color: var(--warn); } .badge-deleting { border-color: #f5b3b3; background: var(--danger-subtle); color: var(--danger); } +.badge-setup-ready { border-color: #aedcb5; background: var(--success-subtle); color: var(--success); } +.badge-setup-setup_path { border-color: #c6d9f0; background: var(--accent-subtle); color: var(--accent); } +.badge-setup-install_agents, +.badge-setup-configure_outputs { border-color: #e3c97a; background: var(--warn-subtle); color: var(--warn); } +.badge-setup-review { border-color: #f5b3b3; background: var(--danger-subtle); color: var(--danger); } /* ── Workspace management panel ───────────────────────────────── */ .workspace-operations-shell { @@ -519,6 +526,78 @@ header .logout-btn:hover, color: var(--ink-secondary); } +.workspace-setup-checklist { + display: grid; + gap: 8px; +} + +.workspace-setup-step { + display: grid; + grid-template-columns: 78px minmax(0, 1fr); + gap: 10px; + align-items: start; + padding: 10px 12px; + border: 1px solid var(--border-muted); + border-radius: var(--radius); + background: var(--bg-subtle); +} + +.workspace-setup-step strong, +.workspace-setup-step span { + display: block; +} + +.workspace-setup-step strong { + color: var(--ink); + font-size: 13px; +} + +.workspace-setup-step div > span { + margin-top: 2px; + color: var(--ink-secondary); + font-size: 12px; +} + +.workspace-setup-status { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 22px; + padding: 2px 7px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg); + color: var(--ink-secondary); + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} + +.workspace-setup-status-done, +.workspace-setup-status-available { + border-color: #aedcb5; + background: var(--success-subtle); + color: var(--success); +} + +.workspace-setup-status-next { + border-color: #e3c97a; + background: var(--warn-subtle); + color: var(--warn); +} + +.workspace-setup-status-blocked { + border-color: #f5b3b3; + background: var(--danger-subtle); + color: var(--danger); +} + +.workspace-setup-status-pending { + border-color: #c6d9f0; + background: var(--accent-subtle); + color: var(--accent); +} + .workspace-management-next-steps { display: flex; flex-direction: column; @@ -1449,6 +1528,7 @@ header .logout-btn:hover, .workspace-list-head { display: none; } .workspace-row { grid-template-columns: 1fr; gap: 8px; } + .workspace-list-head { display: none; } .workspace-actions { justify-content: flex-start; width: 100%; } .access-roster-head, @@ -1464,6 +1544,7 @@ header .logout-btn:hover, .portal-support-simple-row { grid-template-columns: 1fr; gap: 4px; } .workspace-management-facts { grid-template-columns: 1fr; } + .workspace-setup-step { grid-template-columns: 1fr; } .workspace-next-step { grid-template-columns: 1fr; } .workspace-next-step form { justify-self: flex-start; } } diff --git a/internal/cloudcp/portal/frontend/src/types.ts b/internal/cloudcp/portal/frontend/src/types.ts index 6864efe71..7f9582a53 100644 --- a/internal/cloudcp/portal/frontend/src/types.ts +++ b/internal/cloudcp/portal/frontend/src/types.ts @@ -4,6 +4,10 @@ export interface PortalWorkspaceSummary { state: string; healthy: boolean; health_status: 'healthy' | 'checking' | 'unhealthy'; + setup_status?: 'ready' | 'setup_path' | 'install_agents' | 'configure_outputs' | 'review'; + agent_count?: number; + alert_route_count?: number; + report_schedule_count?: number; last_health_check?: string; created_at?: string; } diff --git a/internal/cloudcp/portal/frontend/src/workspace_presentation.test.ts b/internal/cloudcp/portal/frontend/src/workspace_presentation.test.ts index 5030c63fe..dd3a4ad36 100644 --- a/internal/cloudcp/portal/frontend/src/workspace_presentation.test.ts +++ b/internal/cloudcp/portal/frontend/src/workspace_presentation.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from 'vitest'; -import { workspaceGuidanceCopy, workspaceHealthLabel, workspaceHealthState, workspaceRowNote, workspaceStatusCopy } from './workspace_presentation'; +import { + workspaceGuidanceCopy, + workspaceHealthLabel, + workspaceHealthState, + workspaceRowNote, + workspaceSetupLabel, + workspaceSetupNextStep, + workspaceSetupState, + workspaceStatusCopy, +} from './workspace_presentation'; import type { PortalWorkspaceSummary } from './types'; function createWorkspace(overrides: Partial = {}): PortalWorkspaceSummary { @@ -16,7 +25,7 @@ function createWorkspace(overrides: Partial = {}): Porta describe('workspace presentation', function() { it('derives consistent literal copy for healthy, pending, unhealthy, and suspended workspaces', function() { - var healthy = createWorkspace(); + var healthy = createWorkspace({ setup_status: 'ready' }); var checking = createWorkspace({ healthy: false, health_status: 'checking' }); var unhealthy = createWorkspace({ healthy: false, health_status: 'unhealthy' }); var suspended = createWorkspace({ state: 'suspended', health_status: 'healthy' }); @@ -40,10 +49,22 @@ describe('workspace presentation', function() { expect(workspaceRowNote(suspended)).toBe('Suspended'); }); - it('uses literal lifecycle guidance instead of commentary', function() { - expect(workspaceGuidanceCopy(createWorkspace())).toBe('This workspace is active. Open it to work inside this client boundary, or install agents to reach the workspace-bound install commands.'); + it('uses literal setup guidance instead of commentary', function() { + expect(workspaceGuidanceCopy(createWorkspace({ setup_status: 'ready' }))).toBe('Open the workspace when you need to work inside this client boundary.'); + expect(workspaceGuidanceCopy(createWorkspace())).toBe('Open the workspace or install agents from the workspace-bound setup path.'); expect(workspaceGuidanceCopy(createWorkspace({ healthy: false, health_status: 'checking' }))).toBe('This workspace is active. The latest health check is still pending, but the workspace can still own agent install commands.'); expect(workspaceGuidanceCopy(createWorkspace({ healthy: false, health_status: 'unhealthy' }))).toBe('The latest health check is unhealthy. Review the current state before suspending or deleting this workspace.'); - expect(workspaceGuidanceCopy(createWorkspace({ state: 'suspended', health_status: 'healthy' }))).toBe('This workspace is suspended. The remaining lifecycle action here is deletion.'); + expect(workspaceGuidanceCopy(createWorkspace({ state: 'suspended', health_status: 'healthy' }))).toBe('This workspace is suspended. The remaining destructive action here is deletion.'); + }); + + it('derives setup state from explicit status and optional setup counts', function() { + expect(workspaceSetupState(createWorkspace())).toBe('setup_path'); + expect(workspaceSetupLabel(createWorkspace())).toBe('Setup path'); + expect(workspaceRowNote(createWorkspace())).toBe('Setup path ready'); + expect(workspaceSetupNextStep(createWorkspace({ setup_status: 'install_agents' }))).toBe('Install the first agent from this workspace so client data lands in the right boundary.'); + expect(workspaceSetupState(createWorkspace({ agent_count: 0 }))).toBe('install_agents'); + expect(workspaceSetupState(createWorkspace({ agent_count: 1, alert_route_count: 0, report_schedule_count: 0 }))).toBe('configure_outputs'); + expect(workspaceSetupState(createWorkspace({ agent_count: 1, alert_route_count: 1, report_schedule_count: 1 }))).toBe('ready'); + expect(workspaceSetupState(createWorkspace({ state: 'failed', healthy: false, health_status: 'unhealthy' }))).toBe('review'); }); }); diff --git a/internal/cloudcp/portal/frontend/src/workspace_presentation.ts b/internal/cloudcp/portal/frontend/src/workspace_presentation.ts index e0bc189df..a1f6ffe09 100644 --- a/internal/cloudcp/portal/frontend/src/workspace_presentation.ts +++ b/internal/cloudcp/portal/frontend/src/workspace_presentation.ts @@ -1,5 +1,7 @@ import type { PortalWorkspaceSummary } from './types'; +export type WorkspaceSetupState = 'ready' | 'setup_path' | 'install_agents' | 'configure_outputs' | 'review'; + export function workspaceHealthState(workspace: PortalWorkspaceSummary): 'healthy' | 'checking' | 'unhealthy' { if (workspace.health_status === 'healthy' || workspace.health_status === 'checking' || workspace.health_status === 'unhealthy') { return workspace.health_status; @@ -31,16 +33,100 @@ export function workspaceRowNote(workspace: PortalWorkspaceSummary): string { var state = String(workspace.state || ''); if (state === 'suspended') return 'Suspended'; if (state === 'failed') return 'Failed'; - if (status === 'healthy') return 'Ready'; + if (status === 'checking') return 'Health check pending'; if (status === 'unhealthy') return 'Unhealthy'; - return 'Health check pending'; + var setup = workspaceSetupState(workspace); + if (setup === 'ready') return 'Ready'; + if (setup === 'install_agents') return 'Install first agent'; + if (setup === 'configure_outputs') return 'Configure alerts and reports'; + if (setup === 'setup_path') return 'Setup path ready'; + if (setup === 'review') return 'Review'; + return 'Ready'; +} + +function hasNumber(value: unknown): boolean { + return typeof value === 'number' && Number.isFinite(value); +} + +function positiveCount(value: unknown): boolean { + return hasNumber(value) && Number(value) > 0; +} + +function explicitSetupStatus(workspace: PortalWorkspaceSummary): WorkspaceSetupState | '' { + var value = String(workspace.setup_status || ''); + if ( + value === 'ready' || + value === 'setup_path' || + value === 'install_agents' || + value === 'configure_outputs' || + value === 'review' + ) { + return value; + } + return ''; +} + +export function workspaceSetupState(workspace: PortalWorkspaceSummary): WorkspaceSetupState { + var explicit = explicitSetupStatus(workspace); + if (explicit) return explicit; + + var state = String(workspace.state || ''); + var health = workspaceHealthState(workspace); + if (state === 'suspended' || state === 'failed' || health === 'unhealthy') return 'review'; + if (state !== 'active' || health === 'checking') return 'setup_path'; + + var knowsAgentCount = hasNumber(workspace.agent_count); + var knowsAlertCount = hasNumber(workspace.alert_route_count); + var knowsReportCount = hasNumber(workspace.report_schedule_count); + var hasAgents = positiveCount(workspace.agent_count); + var hasAlerts = positiveCount(workspace.alert_route_count); + var hasReports = positiveCount(workspace.report_schedule_count); + + if (knowsAgentCount && !hasAgents) return 'install_agents'; + if (hasAgents && ((knowsAlertCount && !hasAlerts) || (knowsReportCount && !hasReports))) { + return 'configure_outputs'; + } + if (hasAgents && (!knowsAlertCount || hasAlerts) && (!knowsReportCount || hasReports)) { + return 'ready'; + } + return 'setup_path'; +} + +export function workspaceSetupLabel(workspace: PortalWorkspaceSummary): string { + switch (workspaceSetupState(workspace)) { + case 'ready': + return 'Ready'; + case 'install_agents': + return 'Install agent'; + case 'configure_outputs': + return 'Configure outputs'; + case 'review': + return 'Review'; + default: + return 'Setup path'; + } +} + +export function workspaceSetupNextStep(workspace: PortalWorkspaceSummary): string { + switch (workspaceSetupState(workspace)) { + case 'ready': + return 'Open the workspace when you need to work inside this client boundary.'; + case 'install_agents': + return 'Install the first agent from this workspace so client data lands in the right boundary.'; + case 'configure_outputs': + return 'Configure alert routing and reports before treating the client workspace as ready.'; + case 'review': + return 'Review the workspace state before continuing setup.'; + default: + return 'Open the workspace or install agents from the workspace-bound setup path.'; + } } export function workspaceGuidanceCopy(workspace: PortalWorkspaceSummary): string { var status = workspaceHealthState(workspace); var state = String(workspace.state || ''); if (state === 'active' && status === 'healthy') { - return 'This workspace is active. Open it to work inside this client boundary, or install agents to reach the workspace-bound install commands.'; + return workspaceSetupNextStep(workspace); } if (state === 'active' && status === 'checking') { return 'This workspace is active. The latest health check is still pending, but the workspace can still own agent install commands.'; @@ -49,7 +135,7 @@ export function workspaceGuidanceCopy(workspace: PortalWorkspaceSummary): string return 'The latest health check is unhealthy. Review the current state before suspending or deleting this workspace.'; } if (state === 'suspended') { - return 'This workspace is suspended. The remaining lifecycle action here is deletion.'; + return 'This workspace is suspended. The remaining destructive action here is deletion.'; } - return 'Review the current lifecycle state before taking action on this workspace.'; + return 'Review the current workspace state before taking action on this workspace.'; } diff --git a/internal/cloudcp/portal/handlers.go b/internal/cloudcp/portal/handlers.go index 950becc9c..ebba81679 100644 --- a/internal/cloudcp/portal/handlers.go +++ b/internal/cloudcp/portal/handlers.go @@ -25,6 +25,7 @@ type workspaceSummaryItem struct { DisplayName string `json:"display_name"` State registry.TenantState `json:"state"` HealthCheckOK bool `json:"health_check_ok"` + SetupStatus string `json:"setup_status,omitempty"` LastHealthCheck *time.Time `json:"last_health_check"` CreatedAt time.Time `json:"created_at"` } @@ -124,6 +125,7 @@ func HandlePortalDashboard(reg *registry.TenantRegistry) http.HandlerFunc { DisplayName: t.DisplayName, State: t.State, HealthCheckOK: t.HealthCheckOK, + SetupStatus: workspaceSetupStatus(t.State, t.HealthCheckOK, t.LastHealthCheck), LastHealthCheck: t.LastHealthCheck, CreatedAt: t.CreatedAt, }) diff --git a/internal/cloudcp/portal/handlers_test.go b/internal/cloudcp/portal/handlers_test.go index 0c8055a72..a10157f00 100644 --- a/internal/cloudcp/portal/handlers_test.go +++ b/internal/cloudcp/portal/handlers_test.go @@ -119,6 +119,7 @@ type dashboardResp struct { DisplayName string `json:"display_name"` State registry.TenantState `json:"state"` HealthCheckOK bool `json:"health_check_ok"` + SetupStatus string `json:"setup_status"` LastHealthCheck *time.Time `json:"last_health_check"` CreatedAt time.Time `json:"created_at"` } `json:"workspaces"` @@ -240,6 +241,7 @@ func TestPortalDashboard(t *testing.T) { DisplayName: ws.DisplayName, State: ws.State, HealthCheckOK: ws.HealthCheckOK, + SetupStatus: ws.SetupStatus, LastHealthCheck: ws.LastHealthCheck, CreatedAt: ws.CreatedAt, } @@ -258,6 +260,9 @@ func TestPortalDashboard(t *testing.T) { if !active.HealthCheckOK { t.Fatalf("active.health_check_ok = false, want true") } + if active.SetupStatus != "setup_path" { + t.Fatalf("active.setup_status = %q, want setup_path", active.SetupStatus) + } if active.LastHealthCheck == nil || !active.LastHealthCheck.Equal(lastCheck) { t.Fatalf("active.last_health_check = %v, want %v", active.LastHealthCheck, lastCheck) } @@ -272,6 +277,9 @@ func TestPortalDashboard(t *testing.T) { if susp.State != registry.TenantStateSuspended { t.Fatalf("suspended.state = %q, want %q", susp.State, registry.TenantStateSuspended) } + if susp.SetupStatus != "review" { + t.Fatalf("suspended.setup_status = %q, want review", susp.SetupStatus) + } if resp.Summary.Total != 2 { t.Fatalf("summary.total = %d, want %d", resp.Summary.Total, 2) @@ -295,6 +303,7 @@ type dashboardRespWorkspace struct { DisplayName string State registry.TenantState HealthCheckOK bool + SetupStatus string LastHealthCheck *time.Time CreatedAt time.Time } @@ -915,6 +924,7 @@ func TestBuildPortalBootstrapJSON_Contract(t *testing.T) { State: "active", Healthy: true, HealthStatus: "healthy", + SetupStatus: "setup_path", LastHealthCheck: &lastHealthCheck, }, }, @@ -1012,6 +1022,9 @@ func TestBuildPortalBootstrapJSON_Contract(t *testing.T) { if got := workspace["health_status"]; got != "healthy" { t.Fatalf("workspace health_status = %#v, want healthy", got) } + if got := workspace["setup_status"]; got != "setup_path" { + t.Fatalf("workspace setup_status = %#v, want setup_path", got) + } if got := workspace["last_health_check"]; got != "2026-03-27T09:00:00Z" { t.Fatalf("workspace last_health_check = %#v, want 2026-03-27T09:00:00Z", got) } @@ -1092,6 +1105,9 @@ func TestHandlePortalBootstrap_Success(t *testing.T) { if got := workspace["created_at"]; got != "2026-03-25T10:00:00Z" { t.Fatalf("workspace created_at = %#v", got) } + if got := workspace["setup_status"]; got != "setup_path" { + t.Fatalf("workspace setup_status = %#v, want setup_path", got) + } } func TestHandlePortalBootstrap_RequiresAuth(t *testing.T) { diff --git a/internal/cloudcp/portal/page.go b/internal/cloudcp/portal/page.go index 6dcd33790..5fe31032a 100644 --- a/internal/cloudcp/portal/page.go +++ b/internal/cloudcp/portal/page.go @@ -19,6 +19,7 @@ type portalPageWorkspace struct { State string Healthy bool HealthStatus string + SetupStatus string LastHealthCheck *time.Time CreatedAt time.Time } @@ -175,6 +176,7 @@ func loadPortalAccountsForUser(reg *registry.TenantRegistry, userID string) ([]p State: string(t.State), Healthy: t.HealthCheckOK, HealthStatus: workspaceHealthStatus(t.HealthCheckOK, t.LastHealthCheck), + SetupStatus: workspaceSetupStatus(t.State, t.HealthCheckOK, t.LastHealthCheck), LastHealthCheck: t.LastHealthCheck, CreatedAt: t.CreatedAt, }) @@ -249,6 +251,20 @@ func workspaceHealthStatus(healthy bool, lastHealthCheck *time.Time) string { return "unhealthy" } +func workspaceSetupStatus(state registry.TenantState, healthy bool, lastHealthCheck *time.Time) string { + switch state { + case registry.TenantStateActive: + if !healthy && lastHealthCheck != nil { + return "review" + } + return "setup_path" + case registry.TenantStateProvisioning: + return "setup_path" + default: + return "review" + } +} + func renderPortalPage(w http.ResponseWriter, nonce string, faviconHref string, bootstrapData BootstrapData) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) diff --git a/internal/cloudcp/public_msp_signup_handlers.go b/internal/cloudcp/public_msp_signup_handlers.go index d8c4dfb72..c6923aaa5 100644 --- a/internal/cloudcp/public_msp_signup_handlers.go +++ b/internal/cloudcp/public_msp_signup_handlers.go @@ -139,7 +139,7 @@ var publicMSPSignupPageTemplate = template.Must(template.New("public-msp-signup-

Start Pulse MSP Starter

-

Run Pulse for multiple clients from one hosted operator account. Each client gets an isolated workspace; you manage them all from the MSP portal. Starter is the self-serve checkout path for small providers. Growth and Scale are request-based so onboarding, reporting, and support expectations can be sized with you.

+

Run Pulse for multiple clients from one hosted operator account. Each client gets an isolated workspace; you manage them all from Pulse Account. Starter is the self-serve checkout path for small providers. Growth and Scale have published prices, but access is request-assisted so onboarding, reporting, and support expectations can be sized with you.

{{if .ErrorMessage}}
{{.ErrorMessage}}
{{end}} {{if .Cancelled}}
Checkout was cancelled. You can start again below.
{{end}} @@ -148,7 +148,8 @@ var publicMSPSignupPageTemplate = template.Must(template.New("public-msp-signup-
Starter — up to 5 client workspaces, $149/mo
-
Growth / Scale — request access for 15+ client workspaces, custom onboarding, or assisted rollout.
+
Growth — up to 15 client workspaces, $249/mo, request-assisted access
+
Scale — up to 40 client workspaces, $399/mo, request-assisted access
@@ -160,7 +161,7 @@ var publicMSPSignupPageTemplate = template.Must(template.New("public-msp-signup- -

After checkout, we will email a Pulse Account sign-in link so you can open your MSP portal. For Growth or Scale, email support@pulserelay.pro and include your expected client workspace count.

+

After checkout, we will email a Pulse Account sign-in link so you can create client workspaces and continue setup. For Growth or Scale, email support@pulserelay.pro and include the tier you want and your expected client workspace count.

  1. Stripe securely starts your MSP Starter subscription.
  2. Pulse provisions your MSP operator account after checkout completes.
  3. @@ -195,7 +196,7 @@ var publicMSPSignupCompleteTemplate = template.Must(template.New("public-msp-sig

    Checkout complete

    Your Pulse MSP Starter checkout completed. Pulse is provisioning your MSP operator account.

    -

    Watch your inbox for a Pulse Account sign-in link. That link lands in Pulse Account, where you can open the MSP portal, add client workspaces, and continue setup.

    +

    Watch your inbox for a Pulse Account sign-in link. That link lands in Pulse Account, where you can add client workspaces and continue setup.

diff --git a/internal/cloudcp/public_msp_signup_handlers_test.go b/internal/cloudcp/public_msp_signup_handlers_test.go index 05c42753a..a9a414eb7 100644 --- a/internal/cloudcp/public_msp_signup_handlers_test.go +++ b/internal/cloudcp/public_msp_signup_handlers_test.go @@ -143,14 +143,14 @@ func TestMSPSignupPageKeepsStarterSelfServeWhenMultipleTiersConfigured(t *testin if !strings.Contains(body, `type="hidden" name="tier" value="starter"`) { t.Fatal("expected hidden starter tier input") } - if !strings.Contains(body, "Growth / Scale") || !strings.Contains(body, "request access") { - t.Fatal("expected assisted Growth/Scale copy") + if !strings.Contains(body, "Growth") || !strings.Contains(body, "$249/mo") || !strings.Contains(body, "request-assisted access") { + t.Fatal("expected assisted Growth copy") } if !strings.Contains(body, "up to 5 client workspaces") { t.Fatal("expected canonical MSP Starter workspace limit copy") } - if !strings.Contains(body, "15+ client workspaces") { - t.Fatal("expected assisted MSP workspace threshold copy") + if !strings.Contains(body, "Scale") || !strings.Contains(body, "$399/mo") || !strings.Contains(body, "up to 40 client workspaces") { + t.Fatal("expected assisted Scale copy") } } @@ -407,8 +407,8 @@ func TestMSPSignupCompleteRendersHandoff(t *testing.T) { if strings.Contains(strings.ToLower(body), "trial") { t.Fatal("MSP checkout completion should not advertise a trial") } - if !strings.Contains(body, "MSP portal") { - t.Fatal("expected MSP portal handoff copy") + if !strings.Contains(body, "Pulse Account") || !strings.Contains(body, "add client workspaces") { + t.Fatal("expected Pulse Account handoff copy") } } diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 4ade7e951..28c2050ac 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -2860,7 +2860,7 @@ class SubsystemLookupTest(unittest.TestCase): { "heading": "## Shared Boundaries", "path": "internal/api/access_control_handlers.go", - "line": 294, + "line": 306, "heading_line": 112, } ],