fix(cloudcp): make Pulse Account health states explicit

This commit is contained in:
rcourtman
2026-03-27 09:23:14 +00:00
parent c49b599739
commit da78015b96
12 changed files with 110 additions and 29 deletions
@@ -154,6 +154,14 @@ It adds:
3. per-workspace health summary
4. account roles suitable for owner/admin/tech/read-only workflows
Workspace health in Pulse Account must distinguish three states explicitly:
1. `healthy`
2. `checking` when no completed health check exists yet
3. `unhealthy` when the latest health check failed
The portal must not label a failed health check as `checking`.
### Pulse Relay and Pulse Mobile
Pulse Relay does not get a standalone portal. Relay is a capability inside
@@ -215,6 +215,9 @@ account identity context, signed-out versus signed-in shell state, workspace
summaries, and renderer-owned public, commercial, and control-plane route
configuration, including the canonical bootstrap route path, magic-link request
path, signup path, and stable workspace summary fields such as `created_at`.
That workspace summary contract must expose explicit health semantics: `healthy`
for passing health checks, `checking` only when no completed health check
exists yet, and `unhealthy` for a failed latest health check.
`/portal` is now one bootstrap-driven shell for both anonymous and
authenticated users, so new account frontend work must extend that shared
contract rather than inventing a second local payload shape, reviving separate
+18 -10
View File
@@ -7,11 +7,13 @@ import (
)
type BootstrapWorkspace struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
State string `json:"state"`
Healthy bool `json:"healthy"`
CreatedAt string `json:"created_at"`
ID string `json:"id"`
DisplayName string `json:"display_name"`
State string `json:"state"`
Healthy bool `json:"healthy"`
HealthStatus string `json:"health_status"`
LastHealthCheck string `json:"last_health_check,omitempty"`
CreatedAt string `json:"created_at"`
}
type BootstrapAccount struct {
@@ -54,12 +56,18 @@ func BuildBootstrapData(authenticated bool, email string, accounts []portalPageA
for _, account := range accounts {
workspaces := make([]BootstrapWorkspace, 0, len(account.Workspaces))
for _, workspace := range account.Workspaces {
lastHealthCheck := ""
if workspace.LastHealthCheck != nil {
lastHealthCheck = workspace.LastHealthCheck.UTC().Format(time.RFC3339)
}
workspaces = append(workspaces, BootstrapWorkspace{
ID: workspace.ID,
DisplayName: workspace.DisplayName,
State: workspace.State,
Healthy: workspace.Healthy,
CreatedAt: workspace.CreatedAt.UTC().Format(time.RFC3339),
ID: workspace.ID,
DisplayName: workspace.DisplayName,
State: workspace.State,
Healthy: workspace.Healthy,
HealthStatus: workspace.HealthStatus,
LastHealthCheck: lastHealthCheck,
CreatedAt: workspace.CreatedAt.UTC().Format(time.RFC3339),
})
}
bootstrapAccounts = append(bootstrapAccounts, BootstrapAccount{
+1 -1
View File
@@ -1,5 +1,5 @@
{
"source_hash": "07cc157c9e51171f54b181dc953c685e2687e14af66407867c678e0bcd370540",
"source_hash": "a68f28330c399b792cc04898ffa0d0571018bdb7047dcfc6224a77cd503e3801",
"build_inputs": [
"package.json",
"tsconfig.json",
+4
View File
@@ -91,6 +91,10 @@ header .logout-btn:hover {
background: #dcfce7;
color: #166534;
}
.badge-checking {
background: #eff6ff;
color: #1d4ed8;
}
.badge-unhealthy {
background: #fee2e2;
color: #991b1b;
+10 -1
View File
@@ -1379,6 +1379,15 @@
if (role === "tech") return '<span class="badge badge-role">Tech</span>';
return "";
}
function healthBadgeHTML(workspace) {
if (workspace.health_status === "healthy") {
return '<span class="badge badge-healthy">Healthy</span>';
}
if (workspace.health_status === "unhealthy") {
return '<span class="badge badge-unhealthy">Unhealthy</span>';
}
return '<span class="badge badge-checking">Checking</span>';
}
function renderWorkspaceCard(account, workspace, accountAPIBasePath) {
var state = String(workspace.state || "");
var safeState = escapeHTML(state);
@@ -1394,7 +1403,7 @@
manageAction = '<button type="button" class="btn-danger" data-action="workspace-manage" data-account-id="' + escapeAttr(account.id) + '" data-workspace-id="' + escapeAttr(workspace.id) + '" data-workspace-state="' + escapeAttr(state) + '" data-workspace-name="' + escapeAttr(workspace.display_name) + '">\u22EF</button>';
}
var createdMeta = createdLabel ? '<span class="ws-created">Created ' + escapeHTML(createdLabel) + "</span>" : "";
return '<div class="workspace-card"><div class="ws-info"><span class="ws-name">' + escapeHTML(workspace.display_name) + '</span><div class="ws-meta">' + (workspace.healthy ? '<span class="badge badge-healthy">Healthy</span>' : '<span class="badge badge-unhealthy">Checking</span>') + '<span class="badge badge-' + safeState + '">' + safeState + "</span>" + createdMeta + '</div></div><div class="ws-actions">' + openAction + manageAction + "</div></div>";
return '<div class="workspace-card"><div class="ws-info"><span class="ws-name">' + escapeHTML(workspace.display_name) + '</span><div class="ws-meta">' + healthBadgeHTML(workspace) + '<span class="badge badge-' + safeState + '">' + safeState + "</span>" + createdMeta + '</div></div><div class="ws-actions">' + openAction + manageAction + "</div></div>";
}
function renderAccountSection(account, accountAPIBasePath) {
var workspaces = Array.isArray(account.workspaces) ? account.workspaces : [];
@@ -98,6 +98,8 @@ describe('shell view', function() {
display_name: 'Alpha Workspace',
state: 'active',
healthy: true,
health_status: 'healthy',
last_health_check: '2026-03-26T10:10:00Z',
created_at: '2026-03-26T10:00:00Z',
},
{
@@ -105,6 +107,14 @@ describe('shell view', function() {
display_name: 'Beta Workspace',
state: 'failed',
healthy: false,
health_status: 'unhealthy',
},
{
id: 'ws_pending',
display_name: 'Gamma Workspace',
state: 'provisioning',
healthy: false,
health_status: 'checking',
},
],
},
@@ -117,6 +127,11 @@ describe('shell view', function() {
expect(html).toContain('id="accounts-root"');
expect(html).toContain('Acme MSP');
expect(html).toContain('Alpha Workspace');
expect(html).toContain('Beta Workspace');
expect(html).toContain('Gamma Workspace');
expect(html).toContain('Healthy</span>');
expect(html).toContain('Unhealthy</span>');
expect(html).toContain('Checking</span>');
expect(html).toContain('/api/accounts/acct_1/tenants/ws_active/handoff');
expect(html).toContain('data-action="workspace-manage"');
expect(html).toContain('service-card-button');
@@ -39,6 +39,16 @@ function roleBadgeHTML(role: string): string {
return '';
}
function healthBadgeHTML(workspace: PortalWorkspaceSummary): string {
if (workspace.health_status === 'healthy') {
return '<span class="badge badge-healthy">Healthy</span>';
}
if (workspace.health_status === 'unhealthy') {
return '<span class="badge badge-unhealthy">Unhealthy</span>';
}
return '<span class="badge badge-checking">Checking</span>';
}
function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWorkspaceSummary, accountAPIBasePath: string): string {
var state = String(workspace.state || '');
var safeState = escapeHTML(state);
@@ -75,9 +85,7 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor
'<div class="ws-info">' +
'<span class="ws-name">' + escapeHTML(workspace.display_name) + '</span>' +
'<div class="ws-meta">' +
(workspace.healthy
? '<span class="badge badge-healthy">Healthy</span>'
: '<span class="badge badge-unhealthy">Checking</span>') +
healthBadgeHTML(workspace) +
'<span class="badge badge-' + safeState + '">' + safeState + '</span>' +
createdMeta +
'</div>' +
@@ -15,6 +15,7 @@
.badge-cloud { background: #dcfce7; color: #166534; }
.badge-individual { background: #dcfce7; color: #166534; }
.badge-healthy { background: #dcfce7; color: #166534; }
.badge-checking { background: #eff6ff; color: #1d4ed8; }
.badge-unhealthy { background: #fee2e2; color: #991b1b; }
.badge-active { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }
.badge-suspended { background: #fef9c3; color: #854d0e; border: 1px solid #fef08a; }
@@ -3,6 +3,8 @@ export interface PortalWorkspaceSummary {
display_name: string;
state: string;
healthy: boolean;
health_status: 'healthy' | 'checking' | 'unhealthy';
last_health_check?: string;
created_at?: string;
}
+13 -4
View File
@@ -829,6 +829,7 @@ func TestPortalPageTemplate_AccountServicesRendered(t *testing.T) {
}
func TestBuildPortalBootstrapJSON_Contract(t *testing.T) {
lastHealthCheck := time.Date(2026, 3, 27, 9, 0, 0, 0, time.UTC)
bootstrapJSON, err := MarshalBootstrapJSON(BuildBootstrapData(true, "owner@example.com", []portalPageAccount{
{
ID: "a_test",
@@ -840,10 +841,12 @@ func TestBuildPortalBootstrapJSON_Contract(t *testing.T) {
HasBilling: true,
Workspaces: []portalPageWorkspace{
{
ID: "t_one",
DisplayName: "Tenant One",
State: "active",
Healthy: true,
ID: "t_one",
DisplayName: "Tenant One",
State: "active",
Healthy: true,
HealthStatus: "healthy",
LastHealthCheck: &lastHealthCheck,
},
},
},
@@ -917,6 +920,12 @@ func TestBuildPortalBootstrapJSON_Contract(t *testing.T) {
if got := workspace["healthy"]; got != true {
t.Fatalf("workspace healthy = %#v", got)
}
if got := workspace["health_status"]; got != "healthy" {
t.Fatalf("workspace health_status = %#v, want healthy", 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)
}
if got := workspace["created_at"]; got != "0001-01-01T00:00:00Z" {
t.Fatalf("workspace created_at = %#v", got)
}
+24 -10
View File
@@ -14,11 +14,13 @@ import (
// portalPageWorkspace holds per-workspace display data for the portal template.
type portalPageWorkspace struct {
ID string
DisplayName string
State string
Healthy bool
CreatedAt time.Time
ID string
DisplayName string
State string
Healthy bool
HealthStatus string
LastHealthCheck *time.Time
CreatedAt time.Time
}
// portalPageAccount holds per-account display data including the user's role.
@@ -151,11 +153,13 @@ func loadPortalAccountsForUser(reg *registry.TenantRegistry, userID string) ([]p
continue
}
workspaces = append(workspaces, portalPageWorkspace{
ID: t.ID,
DisplayName: t.DisplayName,
State: string(t.State),
Healthy: t.HealthCheckOK,
CreatedAt: t.CreatedAt,
ID: t.ID,
DisplayName: t.DisplayName,
State: string(t.State),
Healthy: t.HealthCheckOK,
HealthStatus: workspaceHealthStatus(t.HealthCheckOK, t.LastHealthCheck),
LastHealthCheck: t.LastHealthCheck,
CreatedAt: t.CreatedAt,
})
}
@@ -186,6 +190,16 @@ func loadPortalAccountsForUser(reg *registry.TenantRegistry, userID string) ([]p
return accounts, nil
}
func workspaceHealthStatus(healthy bool, lastHealthCheck *time.Time) string {
if healthy {
return "healthy"
}
if lastHealthCheck == nil {
return "checking"
}
return "unhealthy"
}
func renderPortalPage(w http.ResponseWriter, nonce string, bootstrapData BootstrapData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)