diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index 2fa9f4fd34..7b2181efb8 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "b4d41db9eab964bde794244e2c7d9d7e0aa77de1523520c2c448d1f08cfe969f", + "source_hash": "640380cc9fd2cd2036a3ac2a9f949e018d3adfb7a6953cc3aa46c8d799980740", "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 b99d432e4d..b62540a6d0 100644 --- a/internal/cloudcp/portal/dist/portal_app.css +++ b/internal/cloudcp/portal/dist/portal_app.css @@ -953,7 +953,7 @@ header .logout-btn:hover, } .workspace-operations-shell { display: grid; - grid-template-columns: minmax(0, 1.5fr) minmax(320px, 0.9fr); + grid-template-columns: minmax(0, 1.42fr) minmax(360px, 1fr); gap: 14px; align-items: start; padding: 16px; @@ -977,10 +977,9 @@ header .logout-btn:hover, background: #fff; } .workspace-management-content { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 16px; - align-items: end; + display: flex; + flex-direction: column; + gap: 14px; } .workspace-management-header, .team-management-header { @@ -1004,16 +1003,50 @@ header .logout-btn:hover, line-height: 1.5; color: var(--ink); } +.workspace-management-empty-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; +} +.workspace-management-empty-card { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 12px; + background: #fff; +} +.workspace-management-empty-card h4 { + margin: 0; + font-size: 17px; + line-height: 1.1; + letter-spacing: -0.025em; +} +.workspace-management-empty-card p { + margin: 0; + font-size: 13px; + line-height: 1.55; + color: var(--ink-soft); +} +.workspace-management-empty-card-muted { + background: var(--panel-muted); +} +.workspace-management-empty-list { + margin: 0; + padding-left: 18px; + display: flex; + flex-direction: column; + gap: 8px; + color: var(--ink-soft); + font-size: 12px; + line-height: 1.5; +} .workspace-management-empty-actions { display: flex; gap: 10px; flex-wrap: wrap; } -.workspace-management-empty-note { - font-size: 12px; - line-height: 1.5; - color: var(--ink-soft); -} .workspace-management-content { margin-top: 16px; } @@ -1024,6 +1057,54 @@ header .logout-btn:hover, text-transform: uppercase; color: var(--ink-muted); } +.workspace-management-facts { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} +.workspace-management-fact { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel-muted); +} +.workspace-management-fact span { + font-size: 11px; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-muted); +} +.workspace-management-fact strong { + font-size: 15px; + line-height: 1.2; + letter-spacing: -0.02em; + color: var(--ink); +} +.workspace-management-guidance { + padding: 14px; + border: 1px solid var(--line); + border-radius: 10px; + background: #fff; + font-size: 13px; + line-height: 1.55; + color: var(--ink-soft); +} +.workspace-management-actions { + display: flex; + justify-content: flex-start; + align-items: center; + gap: 10px; + margin-top: 4px; +} +.workspace-management-actions .btn-danger { + width: 100%; + justify-content: center; + max-width: 220px; +} .add-workspace-form, .team-section, .service-panel { @@ -1697,6 +1778,9 @@ header .logout-btn:hover, .workspace-management-content { grid-template-columns: 1fr; } + .workspace-management-facts { + grid-template-columns: 1fr; + } .workspace-list-head { display: none; } diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index ceb8c1d250..d78f12e24f 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -54,6 +54,32 @@ if (workspace.health_status === "unhealthy") return "This workspace needs attention before it is trustworthy."; return "This workspace is still waiting on a completed health check."; } + function workspaceHealthLabel(workspace) { + if (workspace.health_status === "healthy") return "Healthy"; + if (workspace.health_status === "unhealthy") return "Needs attention"; + return "Checking"; + } + function workspaceCreatedLabel(workspace) { + if (!workspace.created_at) return "Unknown"; + var date = new Date(workspace.created_at); + if (Number.isNaN(date.getTime())) return "Unknown"; + return date.toLocaleDateString(void 0, { month: "short", day: "numeric", year: "numeric" }); + } + function workspaceGuidance(workspace) { + if (workspace.state === "active" && workspace.health_status === "healthy") { + return "This workspace looks ready for normal operator work. Use the fleet table to open it, or suspend it here if you are intentionally taking it out of service."; + } + if (workspace.state === "active" && workspace.health_status === "checking") { + return "This workspace is active but still waiting on a completed health check. Review it before you treat the hosted posture as settled."; + } + if (workspace.health_status === "unhealthy") { + return "This workspace needs review before it is treated as trustworthy. Use the management action only when you intend to suspend or remove it from the hosted fleet."; + } + if (workspace.state === "suspended") { + return "This workspace is already suspended. The remaining lifecycle action here is deletion, so treat it as a deliberate irreversible step."; + } + return "Review the lifecycle state before taking the next explicit action for this workspace."; + } function workspaceMeta(workspace) { var parts = [workspace.state]; if (workspace.health_status) parts.push(workspace.health_status); @@ -79,9 +105,13 @@ var title = getElement("workspace-management-title-" + account.id); var meta = getElement("workspace-management-meta-" + account.id); var summary = getElement("workspace-management-summary-" + account.id); + var health = getElement("workspace-management-health-" + account.id); + var lifecycle = getElement("workspace-management-lifecycle-" + account.id); + var created = getElement("workspace-management-created-" + account.id); + var guidance = getElement("workspace-management-guidance-" + account.id); var actionButton = getElement("workspace-management-action-" + account.id); var closeButton = getElement("workspace-management-close-" + account.id); - if (!empty || !content || !title || !meta || !summary || !actionButton || !closeButton) return; + if (!empty || !content || !title || !meta || !summary || !health || !lifecycle || !created || !guidance || !actionButton || !closeButton) return; var workspace = entry.selectedWorkspaceID ? findWorkspace(account, entry.selectedWorkspaceID) : null; var hasSelection = !!workspace; panel.classList.add("visible"); @@ -97,6 +127,10 @@ title.textContent = workspace.display_name; meta.textContent = workspaceMeta(workspace); summary.textContent = workspaceSummary(workspace); + health.textContent = workspaceHealthLabel(workspace); + lifecycle.textContent = workspace.state ? workspace.state.charAt(0).toUpperCase() + workspace.state.slice(1) : "Unknown"; + created.textContent = workspaceCreatedLabel(workspace); + guidance.textContent = workspaceGuidance(workspace); actionButton.textContent = workspaceActionLabel(workspace); actionButton.disabled = entry.manageWorkspace.pending; actionButton.setAttribute("data-workspace-id", workspace.id); @@ -1816,7 +1850,7 @@ workspaceDeskActions += ''; } workspaceDeskActions += ''; - workspaceManagement = '

Review one workspace at a time

Select a workspace from the fleet to review its lifecycle state and run explicit management actions.

Choose a workspace to manage from the fleet above.
' + workspaceDeskActions + '
Use the fleet table for workspace-level work, or run account-wide billing and team actions from here.
'; + workspaceManagement = '

Review one workspace at a time

Select a workspace from the fleet to review its lifecycle state and run explicit management actions.

Choose a workspace from the fleet to open its management desk.

Keep account-wide actions close

Use the management desk for workspace-level decisions, and keep billing or team changes one click away.

' + workspaceDeskActions + '
  • Lifecycle and health summary for the selected workspace
  • The next explicit lifecycle action for that workspace
  • Account-level actions when you need billing or team changes instead
'; } var workspaceHTML = workspaces.length ? '
Review the hosted fleet, open a workspace, and keep lifecycle actions explicit.
' + String(workspaces.length) + ' total' + String(healthyCount) + ' healthy' + String(checkingCount) + ' checking' + String(unhealthyCount) + ' needs attention
WorkspaceHealthLifecycleActions
' + workspaces.map(function(workspace) { return renderWorkspaceCard(account, workspace, accountAPIBasePath); diff --git a/internal/cloudcp/portal/frontend/src/account_runtime.test.ts b/internal/cloudcp/portal/frontend/src/account_runtime.test.ts index df6abff031..5e052928ac 100644 --- a/internal/cloudcp/portal/frontend/src/account_runtime.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_runtime.test.ts @@ -81,7 +81,7 @@ describe('account runtime', function() { '
' + '' + '' + - '
' + + '
' + '
'; runtime.toggleAddWorkspace('acct_1'); @@ -139,6 +139,10 @@ describe('account runtime', function() { '
' + '

' + '

' + + '
' + + '
' + + '
' + + '
' + '' + '
' + '
'; @@ -189,7 +193,7 @@ describe('account runtime', function() { ); document.body.innerHTML = - '
' + + '
' + '
' + '
' + '
' + diff --git a/internal/cloudcp/portal/frontend/src/account_view.test.ts b/internal/cloudcp/portal/frontend/src/account_view.test.ts index db747e0e3f..b07a5fb8ed 100644 --- a/internal/cloudcp/portal/frontend/src/account_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_view.test.ts @@ -148,6 +148,10 @@ describe('account view', function() { '
' + '

' + '

' + + '
' + + '
' + + '
' + + '
' + '' + '
' + ''; @@ -157,16 +161,17 @@ describe('account view', function() { expect((document.getElementById('workspace-management-empty-acct_1') as HTMLElement).hidden).toBe(true); 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-action-acct_1')?.textContent).toContain('Suspend workspace'); }); it('renders account UI for every tracked account entry', function() { document.body.innerHTML = '
' + - '
' + + '
' + '
' + '
' + - '
' + + '
' + '
'; var state: PortalAccountState = { diff --git a/internal/cloudcp/portal/frontend/src/account_view.ts b/internal/cloudcp/portal/frontend/src/account_view.ts index 35dfb78ea5..2f5ae96dfa 100644 --- a/internal/cloudcp/portal/frontend/src/account_view.ts +++ b/internal/cloudcp/portal/frontend/src/account_view.ts @@ -70,6 +70,35 @@ function workspaceSummary(workspace: PortalWorkspaceSummary): string { return 'This workspace is still waiting on a completed health check.'; } +function workspaceHealthLabel(workspace: PortalWorkspaceSummary): string { + if (workspace.health_status === 'healthy') return 'Healthy'; + if (workspace.health_status === 'unhealthy') return 'Needs attention'; + return 'Checking'; +} + +function workspaceCreatedLabel(workspace: PortalWorkspaceSummary): string { + if (!workspace.created_at) return 'Unknown'; + var date = new Date(workspace.created_at); + if (Number.isNaN(date.getTime())) return 'Unknown'; + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function workspaceGuidance(workspace: PortalWorkspaceSummary): string { + if (workspace.state === 'active' && workspace.health_status === 'healthy') { + return 'This workspace looks ready for normal operator work. Use the fleet table to open it, or suspend it here if you are intentionally taking it out of service.'; + } + if (workspace.state === 'active' && workspace.health_status === 'checking') { + return 'This workspace is active but still waiting on a completed health check. Review it before you treat the hosted posture as settled.'; + } + if (workspace.health_status === 'unhealthy') { + return 'This workspace needs review before it is treated as trustworthy. Use the management action only when you intend to suspend or remove it from the hosted fleet.'; + } + if (workspace.state === 'suspended') { + return 'This workspace is already suspended. The remaining lifecycle action here is deletion, so treat it as a deliberate irreversible step.'; + } + return 'Review the lifecycle state before taking the next explicit action for this workspace.'; +} + function workspaceMeta(workspace: PortalWorkspaceSummary): string { var parts = [workspace.state]; if (workspace.health_status) parts.push(workspace.health_status); @@ -97,9 +126,13 @@ export function renderWorkspaceManagement(account: PortalAccountSummary, entry: var title = getElement('workspace-management-title-' + account.id); var meta = getElement('workspace-management-meta-' + account.id); var summary = getElement('workspace-management-summary-' + account.id); + var health = getElement('workspace-management-health-' + account.id); + var lifecycle = getElement('workspace-management-lifecycle-' + account.id); + var created = getElement('workspace-management-created-' + account.id); + var guidance = getElement('workspace-management-guidance-' + account.id); var actionButton = getElement('workspace-management-action-' + account.id); var closeButton = getElement('workspace-management-close-' + account.id); - if (!empty || !content || !title || !meta || !summary || !actionButton || !closeButton) return; + if (!empty || !content || !title || !meta || !summary || !health || !lifecycle || !created || !guidance || !actionButton || !closeButton) return; var workspace = entry.selectedWorkspaceID ? findWorkspace(account, entry.selectedWorkspaceID) : null; var hasSelection = !!workspace; @@ -118,6 +151,10 @@ export function renderWorkspaceManagement(account: PortalAccountSummary, entry: title.textContent = workspace.display_name; meta.textContent = workspaceMeta(workspace); summary.textContent = workspaceSummary(workspace); + health.textContent = workspaceHealthLabel(workspace); + lifecycle.textContent = workspace.state ? workspace.state.charAt(0).toUpperCase() + workspace.state.slice(1) : 'Unknown'; + created.textContent = workspaceCreatedLabel(workspace); + guidance.textContent = workspaceGuidance(workspace); actionButton.textContent = workspaceActionLabel(workspace); actionButton.disabled = entry.manageWorkspace.pending; actionButton.setAttribute('data-workspace-id', workspace.id); diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts index 68e68fe401..d64797a47f 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts @@ -167,7 +167,9 @@ describe('shell view', function() { expect(html).toContain('Open workspace'); expect(html).toContain('data-action="select-workspace"'); expect(html).toContain('Workspace management'); - expect(html).toContain('Choose a workspace to manage from the fleet above.'); + expect(html).toContain('Choose a workspace from the fleet to open its management desk.'); + expect(html).toContain('What shows here'); + expect(html).toContain('Lifecycle and health summary for the selected workspace'); expect(html).toContain('data-action="clear-workspace-selection"'); expect(html).toContain('Team management'); expect(html).toContain('Invite someone new'); diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts index c1234441f7..abe5efb9f9 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.ts @@ -518,9 +518,23 @@ function renderAccountWorkspaceSection(account: PortalAccountSummary, accountAPI '
' + - '
Choose a workspace to manage from the fleet above.
' + - '
' + workspaceDeskActions + '
' + - '
Use the fleet table for workspace-level work, or run account-wide billing and team actions from here.
' + + '
Choose a workspace from the fleet to open its management desk.
' + + '
' + + '
' + + '' + + '

Keep account-wide actions close

' + + '

Use the management desk for workspace-level decisions, and keep billing or team changes one click away.

' + + '
' + workspaceDeskActions + '
' + + '
' + + '
' + + '' + + '
    ' + + '
  • Lifecycle and health summary for the selected workspace
  • ' + + '
  • The next explicit lifecycle action for that workspace
  • ' + + '
  • Account-level actions when you need billing or team changes instead
  • ' + + '
' + + '
' + + '
' + '
' + '

' + + '
' + + '
' + + 'Health' + + '' + + '
' + + '
' + + 'Lifecycle' + + '' + + '
' + + '
' + + 'Created' + + '' + + '
' + + '
' + + '
' + '
' + '