mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
Make MSP workspace setup flow first-class
This commit is contained in:
parent
d92820cf53
commit
dfb9bdcd18
22 changed files with 777 additions and 85 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"source_hash": "43681a0db38909097b68e0cac9790c9ed1bba5137af508c7a765ec21ec38cbbc",
|
||||
"source_hash": "2c71703848e7ca663ac54560164ea200ed01d13f2755242372eb9bc310bf0f66",
|
||||
"build_inputs": [
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
|
|
|
|||
98
internal/cloudcp/portal/dist/portal_app.css
vendored
98
internal/cloudcp/portal/dist/portal_app.css
vendored
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
188
internal/cloudcp/portal/dist/portal_app.js
vendored
188
internal/cloudcp/portal/dist/portal_app.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -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, '"')
|
||||
.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 '<!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 <agent-name></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 });
|
||||
|
|
|
|||
|
|
@ -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>' +
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>' +
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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> — up to 5 client workspaces, $149/mo</div>
|
||||
<div class="tier-option"><strong>Growth / Scale</strong> — request access for 15+ client workspaces, custom onboarding, or assisted rollout.</div>
|
||||
<div class="tier-option"><strong>Growth</strong> — up to 15 client workspaces, $249/mo, request-assisted access</div>
|
||||
<div class="tier-option"><strong>Scale</strong> — 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>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue