mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
fix(cloudcp): make Pulse Account health states explicit
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"source_hash": "07cc157c9e51171f54b181dc953c685e2687e14af66407867c678e0bcd370540",
|
||||
"source_hash": "a68f28330c399b792cc04898ffa0d0571018bdb7047dcfc6224a77cd503e3801",
|
||||
"build_inputs": [
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
|
||||
+4
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user