diff --git a/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md b/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md index d399531bb0..f0238d9029 100644 --- a/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md +++ b/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index bf953c72f3..a626158efb 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/internal/cloudcp/portal/bootstrap.go b/internal/cloudcp/portal/bootstrap.go index e947e496c9..15aaaf0578 100644 --- a/internal/cloudcp/portal/bootstrap.go +++ b/internal/cloudcp/portal/bootstrap.go @@ -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{ diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index d35c7ba724..7d6f0cdcbc 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "07cc157c9e51171f54b181dc953c685e2687e14af66407867c678e0bcd370540", + "source_hash": "a68f28330c399b792cc04898ffa0d0571018bdb7047dcfc6224a77cd503e3801", "build_inputs": [ "package.json", "tsconfig.json", diff --git a/internal/cloudcp/portal/dist/portal_app.css b/internal/cloudcp/portal/dist/portal_app.css index 88c3205440..75c7bae97e 100644 --- a/internal/cloudcp/portal/dist/portal_app.css +++ b/internal/cloudcp/portal/dist/portal_app.css @@ -91,6 +91,10 @@ header .logout-btn:hover { background: #dcfce7; color: #166534; } +.badge-checking { + background: #eff6ff; + color: #1d4ed8; +} .badge-unhealthy { background: #fee2e2; color: #991b1b; diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index 88c380ebb4..360b1caea4 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -1379,6 +1379,15 @@ if (role === "tech") return 'Tech'; return ""; } + function healthBadgeHTML(workspace) { + if (workspace.health_status === "healthy") { + return 'Healthy'; + } + if (workspace.health_status === "unhealthy") { + return 'Unhealthy'; + } + return 'Checking'; + } function renderWorkspaceCard(account, workspace, accountAPIBasePath) { var state = String(workspace.state || ""); var safeState = escapeHTML(state); @@ -1394,7 +1403,7 @@ manageAction = ''; } var createdMeta = createdLabel ? 'Created ' + escapeHTML(createdLabel) + "" : ""; - return '
' + escapeHTML(workspace.display_name) + '
' + (workspace.healthy ? 'Healthy' : 'Checking') + '' + safeState + "" + createdMeta + '
' + openAction + manageAction + "
"; + return '
' + escapeHTML(workspace.display_name) + '
' + healthBadgeHTML(workspace) + '' + safeState + "" + createdMeta + '
' + openAction + manageAction + "
"; } function renderAccountSection(account, accountAPIBasePath) { var workspaces = Array.isArray(account.workspaces) ? account.workspaces : []; diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts index 3e440ac689..a0eb1042d1 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts @@ -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'); + expect(html).toContain('Unhealthy'); + expect(html).toContain('Checking'); expect(html).toContain('/api/accounts/acct_1/tenants/ws_active/handoff'); expect(html).toContain('data-action="workspace-manage"'); expect(html).toContain('service-card-button'); diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts index 2b414084d9..f67ea82eb7 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.ts @@ -39,6 +39,16 @@ function roleBadgeHTML(role: string): string { return ''; } +function healthBadgeHTML(workspace: PortalWorkspaceSummary): string { + if (workspace.health_status === 'healthy') { + return 'Healthy'; + } + if (workspace.health_status === 'unhealthy') { + return 'Unhealthy'; + } + return 'Checking'; +} + 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 '
' + '' + escapeHTML(workspace.display_name) + '' + '
' + - (workspace.healthy - ? 'Healthy' - : 'Checking') + + healthBadgeHTML(workspace) + '' + safeState + '' + createdMeta + '
' + diff --git a/internal/cloudcp/portal/frontend/src/styles.css b/internal/cloudcp/portal/frontend/src/styles.css index d7cf5dbd4c..5ccbc6e80a 100644 --- a/internal/cloudcp/portal/frontend/src/styles.css +++ b/internal/cloudcp/portal/frontend/src/styles.css @@ -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; } diff --git a/internal/cloudcp/portal/frontend/src/types.ts b/internal/cloudcp/portal/frontend/src/types.ts index 9c8cbd5877..bf68fe4149 100644 --- a/internal/cloudcp/portal/frontend/src/types.ts +++ b/internal/cloudcp/portal/frontend/src/types.ts @@ -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; } diff --git a/internal/cloudcp/portal/handlers_test.go b/internal/cloudcp/portal/handlers_test.go index 7381cc4f39..48912aa2a7 100644 --- a/internal/cloudcp/portal/handlers_test.go +++ b/internal/cloudcp/portal/handlers_test.go @@ -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) } diff --git a/internal/cloudcp/portal/page.go b/internal/cloudcp/portal/page.go index a9ca67fbed..16a3494a1a 100644 --- a/internal/cloudcp/portal/page.go +++ b/internal/cloudcp/portal/page.go @@ -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)