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)