From 1d92aca652940212cb0874e77b51fd69f3709123 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 27 Mar 2026 17:18:55 +0000 Subject: [PATCH] feat(portal): tighten fleet and service views --- .../cloudcp/portal/dist/build_manifest.json | 2 +- internal/cloudcp/portal/dist/portal_app.css | 127 +++++++++++++--- internal/cloudcp/portal/dist/portal_app.js | 25 ++- .../portal/frontend/src/account_view.ts | 15 ++ .../portal/frontend/src/shell_view.test.ts | 3 +- .../cloudcp/portal/frontend/src/shell_view.ts | 78 ++++++---- .../cloudcp/portal/frontend/src/styles.css | 142 +++++++++++++++--- 7 files changed, 319 insertions(+), 73 deletions(-) diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index 3609eaccc2..677ac339ee 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "02f5d00566a4d55ae7999d1121d77557d884ffcedd01d2a278a62f90d61be7a4", + "source_hash": "f5ca96d0cb8760a1108b02f8b7f81a91bf85aa857f46f9317c753d304032ad63", "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 731c612ab6..a2a0894c77 100644 --- a/internal/cloudcp/portal/dist/portal_app.css +++ b/internal/cloudcp/portal/dist/portal_app.css @@ -603,6 +603,58 @@ header .logout-btn:hover, .account-stage-header { padding: 2px 4px 0; } +.workspace-list-wrap { + display: flex; + flex-direction: column; + gap: 8px; +} +.workspace-list-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 4px 4px 10px; +} +.workspace-list-summary { + font-size: 13px; + line-height: 1.55; + color: var(--ink-soft); + max-width: 720px; +} +.workspace-list-stats { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.workspace-list-stat { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + background: var(--panel-muted); + color: var(--ink-soft); + font-size: 12px; +} +.workspace-list-stat strong { + margin-right: 5px; + color: var(--ink); +} +.workspace-list-stat-attention strong { + color: var(--danger); +} +.workspace-list-head { + display: grid; + grid-template-columns: minmax(0, 1.55fr) 120px 120px 180px; + gap: 14px; + padding: 0 16px 6px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-muted); +} .workspace-list { display: flex; flex-direction: column; @@ -804,6 +856,17 @@ header .logout-btn:hover, flex-direction: column; gap: 10px; } +.team-roster-head { + display: grid; + grid-template-columns: minmax(0, 1fr) 120px auto; + gap: 16px; + padding: 0 8px 2px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-muted); +} .team-member-row { display: grid; grid-template-columns: minmax(0, 1fr) 120px auto; @@ -835,6 +898,7 @@ header .logout-btn:hover, justify-content: end; gap: 10px; flex-wrap: wrap; + min-width: 0; } .team-member-role-summary { font-size: 12px; @@ -885,26 +949,45 @@ header .logout-btn:hover, flex-direction: column; gap: 12px; } -.service-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 12px; -} -.service-card { +.service-action-list { display: flex; flex-direction: column; - gap: 8px; - padding: 16px; + gap: 10px; } -.service-card-button { - width: 100%; - text-align: left; - cursor: pointer; +.service-action-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + align-items: center; + padding: 16px 18px; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--panel); } -.service-card:hover, -.service-card-button:hover { - border-color: var(--accent); - box-shadow: var(--shadow-sm); +.service-action-copy { + display: flex; + flex-direction: column; + gap: 6px; +} +.service-action-copy h3 { + margin: 0; + font-size: 19px; + line-height: 1.08; + letter-spacing: -0.03em; +} +.service-action-copy p { + margin: 0; + font-size: 14px; + line-height: 1.6; + color: var(--ink-soft); + max-width: 760px; +} +.service-action-cta { + display: flex; + align-items: center; +} +.service-action-button { + min-width: 92px; } .service-panel { padding: 22px; @@ -1090,6 +1173,9 @@ header .logout-btn:hover, .workspace-management-content { grid-template-columns: 1fr; } + .workspace-list-head { + display: none; + } } @media (max-width: 920px) { .portal-hero { @@ -1099,14 +1185,14 @@ header .logout-btn:hover, .portal-hero-stats { grid-template-columns: 1fr; } - .workspace-list, - .service-grid { - grid-template-columns: 1fr; - } .workspace-row, .workspace-row-main { grid-template-columns: 1fr; } + .workspace-list-toolbar, + .service-action-row { + grid-template-columns: 1fr; + } } @media (max-width: 720px) { header { @@ -1146,6 +1232,7 @@ header .logout-btn:hover, width: 100%; } .team-member-row, + .team-roster-head, .team-member-controls { grid-template-columns: 1fr; align-items: stretch; diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index d4f4cc557e..df220ddbff 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -10,6 +10,8 @@ return "Tech"; case "read_only": return "Read-only"; + case "member": + return "Member"; default: return role || "Member"; } @@ -176,6 +178,14 @@ row.appendChild(controls); return row; } + function ensureRosterHead(container) { + var existing = container.querySelector(".team-roster-head"); + if (existing) return; + var head = document.createElement("div"); + head.className = "team-roster-head"; + head.innerHTML = "OperatorAccessControls"; + container.appendChild(head); + } function renderAddWorkspaceSection(accountID, entry) { var form = getElement("add-ws-form-" + accountID); var spinner = getElement("ws-spinner-" + accountID); @@ -209,6 +219,7 @@ return; } roster.textContent = ""; + ensureRosterHead(roster); for (var i = 0; i < entry.teamQuery.data.length; i += 1) { var member = entry.teamQuery.data[i]; roster.appendChild(renderTeamMemberRow(accountID, member, isOwner)); @@ -1646,6 +1657,9 @@ } return 'Checking'; } + function renderServiceActionRow(id, kicker, title, description, panelID, focusID) { + return '
' + kicker + "

" + title + "

" + description + '

'; + } function workspaceStatusCopy(workspace) { var status = workspaceHealthState(workspace); if (status === "healthy") return "Live updates and health checks are currently good."; @@ -1713,14 +1727,17 @@ } function renderAccountWorkspaceSection(account, accountAPIBasePath) { var workspaces = Array.isArray(account.workspaces) ? account.workspaces : []; + var healthyCount = countWorkspacesByHealth(workspaces, "healthy"); + var checkingCount = countWorkspacesByHealth(workspaces, "checking"); + var unhealthyCount = countWorkspacesByHealth(workspaces, "unhealthy"); var workspaceManagement = ""; if (account.can_manage) { 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.
'; } - var workspaceHTML = workspaces.length ? '
' + workspaces.map(function(workspace) { + 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); - }).join("") + "
" : '

No hosted workspaces yet. Create one to get started.

'; - return '"; + }).join("") + "
" : '

No hosted workspaces yet. Create one to get started.

'; + return '
' + workspaceHTML + '
"; } function renderAccountTeamSection(account) { return '

Control who can operate this account

Owners manage billing and access. Admins and techs keep the hosted fleet running day to day.

People on this account

Keep the roster small and role assignment explicit. The people listed here are the ones who can operate the hosted fleet.

Loading\u2026

Invite someone new

Add another operator with the minimum role they need for this account.

'; @@ -1743,7 +1760,7 @@ var hostedContent = accounts.map(function(account) { return '
"; }).join(""); - return '
' + renderOverviewBand(accounts) + '
' + renderShellNavigation(accounts, context.bootstrap.support_email || "", activeSection) + '

" + (hosted ? "Run hosted accounts from one place" : "No hosted workspaces are attached yet") + "

" + (hosted ? "Use this area for workspace access, fleet operations, hosted billing, and team management." : "This account does not currently have hosted workspace access. If that is unexpected, contact support while using the commercial tools below.") + '

' + hostedContent + '

' + serviceHeading + '

' + serviceNote + '

Data and privacy

Request export or deletion of the commercial data tied to an email address. Payment data held directly by Stripe still requires support handling.

Payment-card data stays with Stripe. For Stripe deletion support, contact ' + escapeHTML(context.bootstrap.support_email || "") + '.
' + renderSupportSection(context) + "
"; + return '
' + renderOverviewBand(accounts) + '
' + renderShellNavigation(accounts, context.bootstrap.support_email || "", activeSection) + '

" + (hosted ? "Hosted operations" : "No hosted workspaces are attached yet") + "

" + (hosted ? "Use this area for workspace access, fleet operations, hosted billing, and team management." : "This account does not currently have hosted workspace access. If that is unexpected, contact support while using the commercial tools below.") + '

' + hostedContent + '

' + serviceHeading + '

' + serviceNote + '
' + renderServiceActionRow("open-manage-service", "Billing", "Manage subscriptions", "Open Stripe billing access for existing self-hosted subscriptions without leaving the Pulse Account shell.", "manage-service-panel", "manage-inline-email") + renderServiceActionRow("open-retrieve-service", "Licenses", "Retrieve licenses", "Recover the latest active self-hosted license and invoice link for a commercial email address.", "retrieve-service-panel", "retrieve-inline-email") + renderServiceActionRow("open-refund-service", "Refunds", "Refund requests", "Request an immediate self-serve refund for eligible self-hosted purchases with explicit revocation confirmation.", "refund-service-panel", "refund-inline-email") + renderServiceActionRow("open-data-service", "Privacy", "Data and privacy", "Request commercial data export or deletion without leaving the account shell.", "data-service-panel", "data-export-email") + '

Data and privacy

Request export or deletion of the commercial data tied to an email address. Payment data held directly by Stripe still requires support handling.

Payment-card data stays with Stripe. For Stripe deletion support, contact ' + escapeHTML(context.bootstrap.support_email || "") + '.
' + renderSupportSection(context) + "
"; } function renderSignedOutPortalHTML(context) { var statusHTML = ""; diff --git a/internal/cloudcp/portal/frontend/src/account_view.ts b/internal/cloudcp/portal/frontend/src/account_view.ts index 9ce7850503..84de7be78c 100644 --- a/internal/cloudcp/portal/frontend/src/account_view.ts +++ b/internal/cloudcp/portal/frontend/src/account_view.ts @@ -18,6 +18,8 @@ function roleLabel(role: string): string { return 'Tech'; case 'read_only': return 'Read-only'; + case 'member': + return 'Member'; default: return role || 'Member'; } @@ -215,6 +217,18 @@ function renderTeamMemberRow(accountID: string, member: PortalTeamMember, isOwne return row; } +function ensureRosterHead(container: HTMLElement): void { + var existing = container.querySelector('.team-roster-head'); + if (existing) return; + var head = document.createElement('div'); + head.className = 'team-roster-head'; + head.innerHTML = + 'Operator' + + 'Access' + + 'Controls'; + container.appendChild(head); +} + export function renderAddWorkspaceSection(accountID: string, entry: PortalAccountUIEntry): void { var form = getElement('add-ws-form-' + accountID); var spinner = getElement('ws-spinner-' + accountID); @@ -252,6 +266,7 @@ export function renderTeamSection(accountID: string, entry: PortalAccountUIEntry } roster.textContent = ''; + ensureRosterHead(roster); for (var i = 0; i < entry.teamQuery.data.length; i += 1) { var member = entry.teamQuery.data[i]; roster.appendChild(renderTeamMemberRow(accountID, member, isOwner)); diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts index 188e3fd0ef..fce39d1961 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts @@ -162,7 +162,8 @@ describe('shell view', function() { expect(html).toContain('Invite someone new'); expect(html).toContain('People on this account'); expect(html).toContain('data-action="workspace-action"'); - expect(html).toContain('service-card-button'); + expect(html).toContain('service-action-row'); + expect(html).toContain('service-action-button'); expect(html).toContain('id="open-retrieve-service"'); expect(html).toContain('id="data-service-panel"'); }); diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts index 5901db2fda..94e8d560ef 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.ts @@ -102,6 +102,28 @@ function healthBadgeHTML(workspace: PortalWorkspaceSummary): string { return 'Checking'; } +function renderServiceActionRow( + id: string, + kicker: string, + title: string, + description: string, + panelID: string, + focusID: string +): string { + return ( + '
' + + '
' + + '' + kicker + '' + + '

' + title + '

' + + '

' + description + '

' + + '
' + + '
' + + '' + + '
' + + '
' + ); +} + function workspaceStatusCopy(workspace: PortalWorkspaceSummary): string { var status = workspaceHealthState(workspace); if (status === 'healthy') return 'Live updates and health checks are currently good.'; @@ -342,6 +364,9 @@ function renderAccountOverviewSection(account: PortalAccountSummary): string { function renderAccountWorkspaceSection(account: PortalAccountSummary, accountAPIBasePath: string): string { var workspaces = Array.isArray(account.workspaces) ? account.workspaces : []; + var healthyCount = countWorkspacesByHealth(workspaces, 'healthy'); + var checkingCount = countWorkspacesByHealth(workspaces, 'checking'); + var unhealthyCount = countWorkspacesByHealth(workspaces, 'unhealthy'); var workspaceManagement = ''; if (account.can_manage) { workspaceManagement = @@ -388,9 +413,26 @@ function renderAccountWorkspaceSection(account: PortalAccountSummary, accountAPI } var workspaceHTML = workspaces.length - ? '
' + workspaces.map(function(workspace) { + ? '
' + + '
' + + '
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' + + '
' + + '
' + + '
' + + 'Workspace' + + 'Health' + + 'Lifecycle' + + 'Actions' + + '
' + + '
' + workspaces.map(function(workspace) { return renderWorkspaceCard(account, workspace, accountAPIBasePath); - }).join('') + '
' + }).join('') + '
' + + '
' : '

No hosted workspaces yet. Create one to get started.

'; return ( @@ -398,8 +440,8 @@ function renderAccountWorkspaceSection(account: PortalAccountSummary, accountAPI '' + workspaceHTML + @@ -562,7 +604,7 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string '
' + '
' + '' + - '

' + (hosted ? 'Run hosted accounts from one place' : 'No hosted workspaces are attached yet') + '

' + + '

' + (hosted ? 'Hosted operations' : 'No hosted workspaces are attached yet') + '

' + '

' + (hosted ? 'Use this area for workspace access, fleet operations, hosted billing, and team management.' : 'This account does not currently have hosted workspace access. If that is unexpected, contact support while using the commercial tools below.') + '

' + @@ -577,27 +619,11 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string '
' + '
' + serviceNote + '
' + '' + - '
' + - '' + - '' + - '' + - '' + + '
' + + renderServiceActionRow('open-manage-service', 'Billing', 'Manage subscriptions', 'Open Stripe billing access for existing self-hosted subscriptions without leaving the Pulse Account shell.', 'manage-service-panel', 'manage-inline-email') + + renderServiceActionRow('open-retrieve-service', 'Licenses', 'Retrieve licenses', 'Recover the latest active self-hosted license and invoice link for a commercial email address.', 'retrieve-service-panel', 'retrieve-inline-email') + + renderServiceActionRow('open-refund-service', 'Refunds', 'Refund requests', 'Request an immediate self-serve refund for eligible self-hosted purchases with explicit revocation confirmation.', 'refund-service-panel', 'refund-inline-email') + + renderServiceActionRow('open-data-service', 'Privacy', 'Data and privacy', 'Request commercial data export or deletion without leaving the account shell.', 'data-service-panel', 'data-export-email') + '
' + '
' + '
' + diff --git a/internal/cloudcp/portal/frontend/src/styles.css b/internal/cloudcp/portal/frontend/src/styles.css index 010aca3189..da68c899be 100644 --- a/internal/cloudcp/portal/frontend/src/styles.css +++ b/internal/cloudcp/portal/frontend/src/styles.css @@ -662,6 +662,66 @@ header .logout-btn:hover, padding: 2px 4px 0; } +.workspace-list-wrap { + display: flex; + flex-direction: column; + gap: 8px; +} + +.workspace-list-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 4px 4px 10px; +} + +.workspace-list-summary { + font-size: 13px; + line-height: 1.55; + color: var(--ink-soft); + max-width: 720px; +} + +.workspace-list-stats { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.workspace-list-stat { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + background: var(--panel-muted); + color: var(--ink-soft); + font-size: 12px; +} + +.workspace-list-stat strong { + margin-right: 5px; + color: var(--ink); +} + +.workspace-list-stat-attention strong { + color: var(--danger); +} + +.workspace-list-head { + display: grid; + grid-template-columns: minmax(0, 1.55fr) 120px 120px 180px; + gap: 14px; + padding: 0 16px 6px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-muted); +} + .workspace-list { display: flex; flex-direction: column; @@ -896,6 +956,18 @@ header .logout-btn:hover, gap: 10px; } +.team-roster-head { + display: grid; + grid-template-columns: minmax(0, 1fr) 120px auto; + gap: 16px; + padding: 0 8px 2px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-muted); +} + .team-member-row { display: grid; grid-template-columns: minmax(0, 1fr) 120px auto; @@ -931,6 +1003,7 @@ header .logout-btn:hover, justify-content: end; gap: 10px; flex-wrap: wrap; + min-width: 0; } .team-member-role-summary { @@ -990,29 +1063,51 @@ header .logout-btn:hover, gap: 12px; } -.service-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 12px; -} - -.service-card { +.service-action-list { display: flex; flex-direction: column; - gap: 8px; - padding: 16px; + gap: 10px; } -.service-card-button { - width: 100%; - text-align: left; - cursor: pointer; +.service-action-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + align-items: center; + padding: 16px 18px; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--panel); } -.service-card:hover, -.service-card-button:hover { - border-color: var(--accent); - box-shadow: var(--shadow-sm); +.service-action-copy { + display: flex; + flex-direction: column; + gap: 6px; +} + +.service-action-copy h3 { + margin: 0; + font-size: 19px; + line-height: 1.08; + letter-spacing: -0.03em; +} + +.service-action-copy p { + margin: 0; + font-size: 14px; + line-height: 1.6; + color: var(--ink-soft); + max-width: 760px; +} + +.service-action-cta { + display: flex; + align-items: center; +} + +.service-action-button { + min-width: 92px; } .service-panel { @@ -1227,6 +1322,10 @@ header .logout-btn:hover, .workspace-management-content { grid-template-columns: 1fr; } + + .workspace-list-head { + display: none; + } } @media (max-width: 920px) { @@ -1239,13 +1338,13 @@ header .logout-btn:hover, grid-template-columns: 1fr; } - .workspace-list, - .service-grid { + .workspace-row, + .workspace-row-main { grid-template-columns: 1fr; } - .workspace-row, - .workspace-row-main { + .workspace-list-toolbar, + .service-action-row { grid-template-columns: 1fr; } } @@ -1295,6 +1394,7 @@ header .logout-btn:hover, } .team-member-row, + .team-roster-head, .team-member-controls { grid-template-columns: 1fr; align-items: stretch;