Make MSP workspace setup flow first-class

This commit is contained in:
rcourtman 2026-06-01 23:49:30 +01:00
parent d92820cf53
commit dfb9bdcd18
22 changed files with 777 additions and 85 deletions

View file

@ -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.

View file

@ -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

View file

@ -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),
})

View file

@ -1,5 +1,5 @@
{
"source_hash": "43681a0db38909097b68e0cac9790c9ed1bba5137af508c7a765ec21ec38cbbc",
"source_hash": "2c71703848e7ca663ac54560164ea200ed01d13f2755242372eb9bc310bf0f66",
"build_inputs": [
"package.json",
"tsconfig.json",

View file

@ -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;
}

File diff suppressed because one or more lines are too long

View file

@ -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, '&lt;')
.replace(/>/g, '&gt;');
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 '<!DOCTYPE html>' +
'<html lang="en">' +
'<head>' +
@ -291,31 +321,48 @@ function buildPreviewWorkspaceHTML(bootstrap, workspaceID, targetPath, scenario)
'<title>' + escapeHTML(title) + ' - Preview workspace</title>' +
'<style>' +
'body{margin:0;font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f6f7f9;color:#101828}' +
'main{max-width:760px;margin:0 auto;padding:48px 20px}' +
'main{max-width:840px;margin:0 auto;padding:48px 20px}' +
'.panel{background:#fff;border:1px solid #d0d5dd;border-radius:8px;padding:24px;box-shadow:0 1px 2px rgba(16,24,40,.06)}' +
'.crumbs{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:18px;color:#667085;font-size:13px}' +
'.crumbs a{color:#155eef;text-decoration:none}' +
'h1{margin:0 0 8px;font-size:24px;letter-spacing:0}' +
'h2{margin:24px 0 8px;font-size:16px}' +
'p{margin:0 0 16px;color:#475467;line-height:1.5}' +
'.facts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:20px 0}' +
'.fact{border:1px solid #eaecf0;border-radius:6px;padding:10px;background:#fcfcfd}' +
'.fact span{display:block;font-size:12px;color:#667085}' +
'.fact strong{display:block;margin-top:2px;font-size:13px}' +
'a{display:inline-flex;align-items:center;min-height:34px;padding:0 12px;border-radius:6px;background:#155eef;color:#fff;text-decoration:none;font-size:14px;font-weight:600}' +
'.task{border:1px solid #eaecf0;border-radius:6px;background:#f8fafc;padding:14px;margin:16px 0}' +
'.task code{display:block;margin:8px 0 0;padding:10px;border:1px solid #d0d5dd;border-radius:6px;background:#fff;color:#101828;white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px}' +
'.button{display:inline-flex;align-items:center;min-height:34px;padding:0 12px;border-radius:6px;background:#155eef;color:#fff;text-decoration:none;font-size:14px;font-weight:600}' +
'.secondary{background:#fff;color:#344054;border:1px solid #d0d5dd}' +
'.actions{display:flex;gap:8px;flex-wrap:wrap}' +
'@media(max-width:640px){.facts{grid-template-columns:1fr}}' +
'</style>' +
'</head>' +
'<body>' +
'<main>' +
'<section class="panel">' +
'<div class="crumbs"><a href="' + escapeHTML(portalWorkspaceURL) + '">Pulse Account</a><span>/</span><span>' + escapeHTML(accountName) + '</span><span>/</span><strong>' + escapeHTML(title) + '</strong></div>' +
'<p>Preview workspace handoff</p>' +
'<h1>' + escapeHTML(title) + '</h1>' +
'<p>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) + '.</p>' +
'<h1>' + escapeHTML(targetHeading) + '</h1>' +
'<p>You are inside the <strong>' + escapeHTML(title) + '</strong> client workspace. In production the signed handoff creates a tenant session and opens ' + escapeHTML(targetLabel) + '.</p>' +
'<div class="facts">' +
'<div class="fact"><span>Account</span><strong>' + escapeHTML(accountName) + '</strong></div>' +
'<div class="fact"><span>Workspace</span><strong>' + escapeHTML(workspaceID) + '</strong></div>' +
'<div class="fact"><span>Target</span><strong>' + escapeHTML(targetPath || '/') + '</strong></div>' +
'<div class="fact"><span>Target</span><strong>' + escapeHTML(targetPathLabel) + '</strong></div>' +
'</div>' +
'<div class="task">' +
'<strong>' + escapeHTML(targetLabel) + '</strong>' +
'<p>' + escapeHTML(taskCopy) + '</p>' +
(targetPath === '/settings/infrastructure?add=linux-host'
? '<code>pulse-agent install --workspace ' + escapeHTML(workspaceID) + ' --name &lt;agent-name&gt;</code>'
: '') +
'</div>' +
'<div class="actions">' +
'<a class="button" href="' + escapeHTML(portalWorkspaceURL) + '">Back to workspace row</a>' +
'<a class="button secondary" href="' + escapeHTML(portalURL) + '">Pulse Account home</a>' +
'</div>' +
'<p>Agents, alerts, and reports are scoped to this workspace after the handoff.</p>' +
'<a href="' + escapeHTML(portalURL) + '">Back to Pulse Account</a>' +
'</section>' +
'</main>' +
'</body>' +
@ -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 });

View file

@ -82,7 +82,7 @@ describe('account runtime', function() {
'<div id="add-ws-form-acct_1" class="add-workspace-form">' +
'<input id="ws-name-acct_1" value="Acme Corp">' +
'<div id="ws-spinner-acct_1" hidden></div>' +
'<div id="workspace-management-acct_1" class="workspace-management-panel"><button id="workspace-management-close-acct_1"></button><div id="workspace-management-empty-acct_1"></div><div id="workspace-management-content-acct_1" hidden><div id="workspace-management-meta-acct_1"></div><h4 id="workspace-management-title-acct_1"></h4><p id="workspace-management-summary-acct_1"></p><div id="workspace-management-health-acct_1"></div><div id="workspace-management-lifecycle-acct_1"></div><div id="workspace-management-created-acct_1"></div><div id="workspace-management-guidance-acct_1"></div><button id="workspace-management-action-acct_1"></button></div></div>' +
'<div id="workspace-management-acct_1" class="workspace-management-panel"><button id="workspace-management-close-acct_1"></button><div id="workspace-management-empty-acct_1"></div><div id="workspace-management-content-acct_1" hidden><div id="workspace-management-meta-acct_1"></div><h4 id="workspace-management-title-acct_1"></h4><p id="workspace-management-summary-acct_1"></p><div id="workspace-management-health-acct_1"></div><div id="workspace-management-setup-acct_1"></div><div id="workspace-management-created-acct_1"></div><div id="workspace-management-guidance-acct_1"></div><button id="workspace-management-action-acct_1"></button></div></div>' +
'</div>';
runtime.toggleAddWorkspace('acct_1');
@ -144,7 +144,7 @@ describe('account runtime', function() {
'<h4 id="workspace-management-title-acct_1"></h4>' +
'<p id="workspace-management-summary-acct_1"></p>' +
'<div id="workspace-management-health-acct_1"></div>' +
'<div id="workspace-management-lifecycle-acct_1"></div>' +
'<div id="workspace-management-setup-acct_1"></div>' +
'<div id="workspace-management-created-acct_1"></div>' +
'<div id="workspace-management-guidance-acct_1"></div>' +
'<button id="workspace-management-action-acct_1"></button>' +
@ -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() {
'<h4 id="workspace-management-title-acct_1"></h4>' +
'<p id="workspace-management-summary-acct_1"></p>' +
'<div id="workspace-management-health-acct_1"></div>' +
'<div id="workspace-management-lifecycle-acct_1"></div>' +
'<div id="workspace-management-setup-acct_1"></div>' +
'<div id="workspace-management-created-acct_1"></div>' +
'<div id="workspace-management-guidance-acct_1"></div>' +
'<button id="workspace-management-action-acct_1"></button>' +
@ -287,7 +287,7 @@ describe('account runtime', function() {
'<h4 id="workspace-management-title-acct_1"></h4>' +
'<p id="workspace-management-summary-acct_1"></p>' +
'<div id="workspace-management-health-acct_1"></div>' +
'<div id="workspace-management-lifecycle-acct_1"></div>' +
'<div id="workspace-management-setup-acct_1"></div>' +
'<div id="workspace-management-created-acct_1"></div>' +
'<div id="workspace-management-guidance-acct_1"></div>' +
'<button id="workspace-management-action-acct_1"></button>' +
@ -413,7 +413,7 @@ describe('account runtime', function() {
);
document.body.innerHTML =
'<div id="workspace-management-acct_1" class="workspace-management-panel"><button id="workspace-management-close-acct_1"></button><div id="workspace-management-empty-acct_1"></div><div id="workspace-management-content-acct_1" hidden><div id="workspace-management-meta-acct_1"></div><h4 id="workspace-management-title-acct_1"></h4><p id="workspace-management-summary-acct_1"></p><div id="workspace-management-health-acct_1"></div><div id="workspace-management-lifecycle-acct_1"></div><div id="workspace-management-created-acct_1"></div><div id="workspace-management-guidance-acct_1"></div><button id="workspace-management-action-acct_1"></button></div></div>' +
'<div id="workspace-management-acct_1" class="workspace-management-panel"><button id="workspace-management-close-acct_1"></button><div id="workspace-management-empty-acct_1"></div><div id="workspace-management-content-acct_1" hidden><div id="workspace-management-meta-acct_1"></div><h4 id="workspace-management-title-acct_1"></h4><p id="workspace-management-summary-acct_1"></p><div id="workspace-management-health-acct_1"></div><div id="workspace-management-setup-acct_1"></div><div id="workspace-management-created-acct_1"></div><div id="workspace-management-guidance-acct_1"></div><button id="workspace-management-action-acct_1"></button></div></div>' +
'<div id="access-section-acct_1" class="access-section" data-actor-role="owner" data-can-manage="true">' +
'<div id="access-shell-acct_1"></div>' +
'<div id="access-detail-acct_1"></div>' +

View file

@ -74,9 +74,14 @@ describe('account view', function() {
'<h4 id="workspace-management-title-' + accountID + '"></h4>' +
'<p id="workspace-management-summary-' + accountID + '"></p>' +
'<div id="workspace-management-health-' + accountID + '"></div>' +
'<div id="workspace-management-lifecycle-' + accountID + '"></div>' +
'<div id="workspace-management-setup-' + accountID + '"></div>' +
'<div id="workspace-management-created-' + accountID + '"></div>' +
'<div id="workspace-management-guidance-' + accountID + '"></div>' +
'<span id="workspace-management-check-created-' + accountID + '"></span>' +
'<span id="workspace-management-check-open-' + accountID + '"></span>' +
'<span id="workspace-management-check-install-' + accountID + '"></span>' +
'<span id="workspace-management-check-outputs-' + accountID + '"></span>' +
'<span id="workspace-management-check-access-' + accountID + '"></span>' +
'<form id="workspace-management-open-form-' + accountID + '"><button id="workspace-management-open-' + accountID + '"></button></form>' +
'<form id="workspace-management-install-form-' + accountID + '"><button id="workspace-management-install-' + accountID + '"></button></form>' +
'<form id="workspace-management-reporting-form-' + accountID + '"><button id="workspace-management-reporting-' + accountID + '"></button></form>' +
@ -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');

View file

@ -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<HTMLElement>('workspace-management-meta-' + account.id);
var summary = getElement<HTMLElement>('workspace-management-summary-' + account.id);
var health = getElement<HTMLElement>('workspace-management-health-' + account.id);
var lifecycle = getElement<HTMLElement>('workspace-management-lifecycle-' + account.id);
var setup = getElement<HTMLElement>('workspace-management-setup-' + account.id);
var created = getElement<HTMLElement>('workspace-management-created-' + account.id);
var guidance = getElement<HTMLElement>('workspace-management-guidance-' + account.id);
var checkCreated = getElement<HTMLElement>('workspace-management-check-created-' + account.id);
var checkOpen = getElement<HTMLElement>('workspace-management-check-open-' + account.id);
var checkInstall = getElement<HTMLElement>('workspace-management-check-install-' + account.id);
var checkOutputs = getElement<HTMLElement>('workspace-management-check-outputs-' + account.id);
var checkAccess = getElement<HTMLElement>('workspace-management-check-access-' + account.id);
var actionButton = getElement<HTMLButtonElement>('workspace-management-action-' + account.id);
var closeButton = getElement<HTMLButtonElement>('workspace-management-close-' + account.id);
var openForm = getElement<HTMLFormElement>('workspace-management-open-form-' + account.id);
@ -104,7 +121,7 @@ export function renderWorkspaceManagement(account: PortalAccountSummary, entry:
var installButton = getElement<HTMLButtonElement>('workspace-management-install-' + account.id);
var reportingForm = getElement<HTMLFormElement>('workspace-management-reporting-form-' + account.id);
var reportingButton = getElement<HTMLButtonElement>('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);

View file

@ -124,7 +124,8 @@ describe('shell view', function() {
expect(html).toContain('workspace-summary-shell');
expect(html).toContain('workspace-summary-facts');
expect(html).toContain('Next:</strong> Open Summary Workspace');
expect(html).toContain('Next:</strong> 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:</strong> 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');

View file

@ -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 '<span class="badge badge-checking">' + escapeHTML(workspaceHealthLabel(workspace)) + '</span>';
}
function setupBadgeHTML(workspace: PortalWorkspaceSummary): string {
var setup = workspaceSetupState(workspace);
return '<span class="badge badge-setup-' + escapeHTML(setup) + '">' + escapeHTML(workspaceSetupLabel(workspace)) + '</span>';
}
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('<span class="workspace-meta-item">' + escapeHTML(titleCase(state)) + '</span>');
}
if (createdLabel) {
metaParts.push('<span class="workspace-meta-item">Created ' + escapeHTML(createdLabel) + '</span>');
}
@ -452,7 +472,7 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor
escapeAttr(account.id) +
'" data-workspace-id="' +
escapeAttr(workspace.id) +
'">Lifecycle</button>';
'">Setup checklist</button>';
}
return (
@ -465,10 +485,10 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor
'<div class="workspace-row-note">' + escapeHTML(workspaceRowNote(workspace)) + '</div>' +
'</div>' +
'<div class="workspace-row-status-cell workspace-row-status-cell-badge">' +
healthBadgeHTML(workspace) +
setupBadgeHTML(workspace) +
'</div>' +
'<div class="workspace-row-status-cell workspace-row-status-cell-badge">' +
'<span class="badge badge-' + escapeHTML(state || 'unknown') + '">' + escapeHTML(titleCase(state || 'Unknown')) + '</span>' +
healthBadgeHTML(workspace) +
'</div>' +
'<div class="workspace-actions">' +
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 '<button class="' + escapeAttr(buttonClassName) + '" type="button" data-shell-action="activate-section" data-shell-section="workspaces">' + escapeHTML(label) + '</button>';
}
return (
'<form method="POST" action="' +
escapeAttr(workspaceHandoffActionPath(accountAPIBasePath, accountID, workspaceID, WORKSPACE_REPORTING_TARGET_PATH)) +
'">' +
'<button type="submit" class="' + escapeAttr(buttonClassName) + '">' + escapeHTML(label) + '</button>' +
'</form>'
);
}
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</button>'
'">Setup checklist</button>'
: '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Open Access</button>';
} 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</button>'
'">Setup checklist</button>'
: '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Open Access</button>';
} 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
? '<button type="button" class="btn-secondary btn-compact" data-action="select-workspace" data-account-id="' +
escapeAttr(setupEntry.account.id) +
'" data-workspace-id="' +
escapeAttr(setupEntry.workspace.id) +
'">Setup checklist</button>'
: 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>' +
'<div class="workspace-management-header">' +
'<div>' +
'<h3>Workspace lifecycle</h3>' +
'<p>Manage this workspace or create a new one.</p>' +
'<h3>Workspace setup checklist</h3>' +
'<p>Finish the client setup steps without mixing client data across workspaces.</p>' +
'</div>' +
'<button type="button" class="btn-secondary btn-compact" id="workspace-management-close-' +
escapeAttr(account.id) +
@ -863,8 +940,8 @@ function renderAccountWorkspaceSection(account: PortalAccountSummary, accountAPI
'<strong id="workspace-management-health-' + escapeAttr(account.id) + '"></strong>' +
'</div>' +
'<div class="workspace-management-fact">' +
'<span>Lifecycle</span>' +
'<strong id="workspace-management-lifecycle-' + escapeAttr(account.id) + '"></strong>' +
'<span>Setup</span>' +
'<strong id="workspace-management-setup-' + escapeAttr(account.id) + '"></strong>' +
'</div>' +
'<div class="workspace-management-fact">' +
'<span>Created</span>' +
@ -874,6 +951,28 @@ function renderAccountWorkspaceSection(account: PortalAccountSummary, accountAPI
'<div class="workspace-management-guidance" id="workspace-management-guidance-' +
escapeAttr(account.id) +
'"></div>' +
'<div class="workspace-setup-checklist" aria-label="Workspace setup checklist">' +
'<div class="workspace-setup-step workspace-setup-step-created">' +
'<span class="workspace-setup-status" id="workspace-management-check-created-' + escapeAttr(account.id) + '"></span>' +
'<div><strong>Workspace created</strong><span>This client has a separate account boundary.</span></div>' +
'</div>' +
'<div class="workspace-setup-step">' +
'<span class="workspace-setup-status" id="workspace-management-check-open-' + escapeAttr(account.id) + '"></span>' +
'<div><strong>Open the workspace</strong><span>Work inside the selected client boundary.</span></div>' +
'</div>' +
'<div class="workspace-setup-step">' +
'<span class="workspace-setup-status" id="workspace-management-check-install-' + escapeAttr(account.id) + '"></span>' +
'<div><strong>Install the first agent</strong><span>Use the workspace-bound install path so data lands in this client.</span></div>' +
'</div>' +
'<div class="workspace-setup-step">' +
'<span class="workspace-setup-status" id="workspace-management-check-outputs-' + escapeAttr(account.id) + '"></span>' +
'<div><strong>Configure alerts and reports</strong><span>Keep notifications and reports scoped to this client.</span></div>' +
'</div>' +
'<div class="workspace-setup-step">' +
'<span class="workspace-setup-status" id="workspace-management-check-access-' + escapeAttr(account.id) + '"></span>' +
'<div><strong>Review access</strong><span>Invite provider staff or client users from Access.</span></div>' +
'</div>' +
'</div>' +
'<div class="workspace-management-next-steps" id="workspace-management-next-steps-' +
escapeAttr(account.id) +
'">' +
@ -907,6 +1006,10 @@ function renderAccountWorkspaceSection(account: PortalAccountSummary, accountAPI
'">Open reports</button>' +
'</form>' +
'</div>' +
'<div class="workspace-next-step workspace-next-step-readonly">' +
'<div><strong>Access</strong><span>Invite people or adjust roles from the account access boundary.</span></div>' +
'<button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="access">Open Access</button>' +
'</div>' +
'</div>' +
'<div class="workspace-management-actions">' +
'<button type="button" class="btn-danger" id="workspace-management-action-' +
@ -925,8 +1028,8 @@ function renderAccountWorkspaceSection(account: PortalAccountSummary, accountAPI
(workspaceHeaderActions ? '<div class="workspace-list-toolbar">' + workspaceHeaderActions + '</div>' : '') +
'<div class="workspace-list-head">' +
'<span>Workspace</span>' +
'<span>Setup</span>' +
'<span>Health</span>' +
'<span>Lifecycle</span>' +
'<span>Actions</span>' +
'</div>' +
'<div class="workspace-list">' + workspaces.map(function(workspace) {
@ -1380,7 +1483,7 @@ export function renderSignedOutPortalHTML(context: ShellViewContext): string {
'<h1>Sign in to Pulse Account</h1>' +
'<p>Use one commercial email address for hosted workspaces, account access, billing, licenses, refunds, and privacy requests.</p>' +
'<div class="portal-auth-scope-list" aria-label="Pulse Account scope">' +
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.') +
'</div>' +

View file

@ -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; }
}

View file

@ -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;
}

View file

@ -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> = {}): PortalWorkspaceSummary {
@ -16,7 +25,7 @@ function createWorkspace(overrides: Partial<PortalWorkspaceSummary> = {}): 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');
});
});

View file

@ -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.';
}

View file

@ -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,
})

View file

@ -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) {

View file

@ -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)

View file

@ -139,7 +139,7 @@ var publicMSPSignupPageTemplate = template.Must(template.New("public-msp-signup-
<div class="wrap">
<div class="card">
<h1>Start Pulse MSP Starter</h1>
<p>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.</p>
<p>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.</p>
{{if .ErrorMessage}}<div class="error">{{.ErrorMessage}}</div>{{end}}
{{if .Cancelled}}<div class="note">Checkout was cancelled. You can start again below.</div>{{end}}
@ -148,7 +148,8 @@ var publicMSPSignupPageTemplate = template.Must(template.New("public-msp-signup-
<input type="hidden" name="tier" value="{{.Tier}}">
<div class="tier-group">
<div class="tier-option"><strong>Starter</strong> &mdash; up to 5 client workspaces, $149/mo</div>
<div class="tier-option"><strong>Growth / Scale</strong> &mdash; request access for 15+ client workspaces, custom onboarding, or assisted rollout.</div>
<div class="tier-option"><strong>Growth</strong> &mdash; up to 15 client workspaces, $249/mo, request-assisted access</div>
<div class="tier-option"><strong>Scale</strong> &mdash; up to 40 client workspaces, $399/mo, request-assisted access</div>
</div>
<label for="email">Work Email</label>
@ -160,7 +161,7 @@ var publicMSPSignupPageTemplate = template.Must(template.New("public-msp-signup-
<button class="cta" type="submit">Continue To Secure Checkout</button>
</form>
<p class="fine">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.</p>
<p class="fine">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.</p>
<ol>
<li>Stripe securely starts your MSP Starter subscription.</li>
<li>Pulse provisions your MSP operator account after checkout completes.</li>
@ -195,7 +196,7 @@ var publicMSPSignupCompleteTemplate = template.Must(template.New("public-msp-sig
<div class="card">
<h1>Checkout complete</h1>
<p>Your Pulse MSP Starter checkout completed. Pulse is provisioning your MSP operator account.</p>
<p>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.</p>
<p>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.</p>
</div>
</div>
</body>

View file

@ -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")
}
}

View file

@ -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,
}
],