mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
feat(portal): tighten fleet and service views
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"source_hash": "02f5d00566a4d55ae7999d1121d77557d884ffcedd01d2a278a62f90d61be7a4",
|
||||
"source_hash": "f5ca96d0cb8760a1108b02f8b7f81a91bf85aa857f46f9317c753d304032ad63",
|
||||
"build_inputs": [
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
|
||||
+107
-20
@@ -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;
|
||||
|
||||
+21
-4
@@ -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 = "<span>Operator</span><span>Access</span><span>Controls</span>";
|
||||
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 '<span class="badge badge-checking">Checking</span>';
|
||||
}
|
||||
function renderServiceActionRow(id, kicker, title, description, panelID, focusID) {
|
||||
return '<div class="service-action-row"><div class="service-action-copy"><span class="service-card-kicker">' + kicker + "</span><h3>" + title + "</h3><p>" + description + '</p></div><div class="service-action-cta"><button class="btn-secondary service-action-button" type="button" id="' + id + '" data-account-service-action="open-service-panel" data-account-service-panel="' + panelID + '" data-account-service-focus="' + focusID + '" data-shell-target="services">Open</button></div></div>';
|
||||
}
|
||||
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 = '<section class="workspace-management-panel" id="workspace-management-' + escapeAttr(account.id) + '"><div class="workspace-management-header"><div><div class="account-panel-kicker">Workspace management</div><h3>Review one workspace at a time</h3><p>Select a workspace from the fleet to review its lifecycle state and run explicit management actions.</p></div><button type="button" class="btn-secondary btn-compact" id="workspace-management-close-' + escapeAttr(account.id) + '" data-action="clear-workspace-selection" data-account-id="' + escapeAttr(account.id) + '">Done</button></div><div class="workspace-management-empty" id="workspace-management-empty-' + escapeAttr(account.id) + '">Choose a workspace to manage from the fleet above.</div><div class="workspace-management-content" id="workspace-management-content-' + escapeAttr(account.id) + '" hidden><div class="workspace-management-meta" id="workspace-management-meta-' + escapeAttr(account.id) + '"></div><h4 id="workspace-management-title-' + escapeAttr(account.id) + '"></h4><p class="workspace-management-summary" id="workspace-management-summary-' + escapeAttr(account.id) + '"></p><div class="workspace-management-actions"><button type="button" class="btn-danger" id="workspace-management-action-' + escapeAttr(account.id) + '" data-action="workspace-action" data-account-id="' + escapeAttr(account.id) + '">Manage workspace</button></div></div></section>';
|
||||
}
|
||||
var workspaceHTML = workspaces.length ? '<div class="workspace-list">' + workspaces.map(function(workspace) {
|
||||
var workspaceHTML = workspaces.length ? '<div class="workspace-list-wrap"><div class="workspace-list-toolbar"><div class="workspace-list-summary">Review the hosted fleet, open a workspace, and keep lifecycle actions explicit.</div><div class="workspace-list-stats"><span class="workspace-list-stat"><strong>' + String(workspaces.length) + '</strong> total</span><span class="workspace-list-stat"><strong>' + String(healthyCount) + '</strong> healthy</span><span class="workspace-list-stat"><strong>' + String(checkingCount) + '</strong> checking</span><span class="workspace-list-stat workspace-list-stat-attention"><strong>' + String(unhealthyCount) + '</strong> needs attention</span></div></div><div class="workspace-list-head"><span>Workspace</span><span>Health</span><span>Lifecycle</span><span>Actions</span></div><div class="workspace-list">' + workspaces.map(function(workspace) {
|
||||
return renderWorkspaceCard(account, workspace, accountAPIBasePath);
|
||||
}).join("") + "</div>" : '<div class="empty-state"><p>No hosted workspaces yet. Create one to get started.</p></div>';
|
||||
return '<section class="account-content-panel account-content-panel-workspaces"><div class="account-stage-header"><div><div class="account-panel-kicker">Workspace fleet</div><h3>Open hosted Pulse workspaces</h3><p>Review fleet health, move into a workspace, and keep lifecycle actions explicit.</p></div></div>' + workspaceHTML + '<div class="account-management-grid">' + workspaceManagement + "</div></section>";
|
||||
}).join("") + "</div></div>" : '<div class="empty-state"><p>No hosted workspaces yet. Create one to get started.</p></div>';
|
||||
return '<section class="account-content-panel account-content-panel-workspaces"><div class="account-stage-header"><div><div class="account-panel-kicker">Workspace fleet</div><h3>Hosted fleet</h3><p>Use the fleet view to open workspaces, watch health posture, and keep management actions explicit.</p></div></div>' + workspaceHTML + '<div class="account-management-grid">' + workspaceManagement + "</div></section>";
|
||||
}
|
||||
function renderAccountTeamSection(account) {
|
||||
return '<section class="account-content-panel account-content-panel-team"><section class="team-management-panel team-section team-section-shell" id="team-section-' + escapeAttr(account.id) + '" data-actor-role="' + escapeAttr(account.role) + '"><div class="team-management-header"><div><div class="account-panel-kicker">Team management</div><h3>Control who can operate this account</h3><p>Owners manage billing and access. Admins and techs keep the hosted fleet running day to day.</p></div><button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="workspaces">Done</button></div><div class="team-management-stats" id="team-stats-' + escapeAttr(account.id) + '"></div><div class="team-management-grid"><div class="team-roster"><div class="team-panel-heading"><h4>People on this account</h4><p>Keep the roster small and role assignment explicit. The people listed here are the ones who can operate the hosted fleet.</p></div><div class="team-roster-list" id="team-list-' + escapeAttr(account.id) + '"><div class="team-list-message">Loading\u2026</div></div></div><div class="team-invite-panel"><div class="team-panel-heading"><h4>Invite someone new</h4><p>Add another operator with the minimum role they need for this account.</p></div><div class="team-invite"><div><label for="invite-email-' + escapeAttr(account.id) + '">Email</label><input type="email" id="invite-email-' + escapeAttr(account.id) + '" placeholder="user@example.com" autocomplete="off"></div><div><label for="invite-role-' + escapeAttr(account.id) + '">Role</label><select id="invite-role-' + escapeAttr(account.id) + '"><option value="admin">Admin</option><option value="tech">Tech</option><option value="read_only">Read-only</option></select></div><button type="button" class="btn-primary btn-compact" data-action="invite-member" data-account-id="' + escapeAttr(account.id) + '">Invite</button></div></div></div></section></section>';
|
||||
@@ -1743,7 +1760,7 @@
|
||||
var hostedContent = accounts.map(function(account) {
|
||||
return '<section class="account-surface"><div class="account-surface-header"><div class="account-heading"><div class="account-eyebrow">' + escapeHTML(accountKindLabel(account)) + "</div><h2>" + escapeHTML(account.name) + '</h2><div class="account-summary">' + escapeHTML(accountKindLabel(account) + " \xB7 " + titleCase(account.role) + " \xB7 " + workspaceCountLabel((account.workspaces || []).length)) + '</div></div><div class="account-badges"><span class="badge badge-' + escapeHTML(account.kind) + '">' + escapeHTML(account.kind_label) + "</span>" + roleBadgeHTML(account.role) + '</div></div><div class="account-surface-body">' + renderAccountOverviewSection(account) + renderAccountWorkspaceSection(account, context.accountAPIBasePath) + renderAccountTeamSection(account) + "</div></section>";
|
||||
}).join("");
|
||||
return '<div class="portal-shell" data-shell-section="' + activeSection + '">' + renderOverviewBand(accounts) + '<div class="portal-shell-layout">' + renderShellNavigation(accounts, context.bootstrap.support_email || "", activeSection) + '<div class="portal-shell-main"><section class="portal-content-panel portal-content-panel-overview"><div class="portal-top-section-header"><div class="account-panel-kicker">' + (hosted ? "Hosted operations" : "Hosted access") + "</div><h2>" + (hosted ? "Run hosted accounts from one place" : "No hosted workspaces are attached yet") + "</h2><p>" + (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.") + '</p></div><div id="accounts-root">' + hostedContent + '</div></section><section class="portal-content-panel portal-content-panel-services service-section" id="account-services-section"><div class="service-header"><div><div class="account-panel-kicker">Account services</div><h2>' + serviceHeading + '</h2></div><div class="service-note">' + serviceNote + '</div></div><div class="service-grid"><button class="service-card service-card-button" type="button" id="open-manage-service" data-account-service-action="open-service-panel" data-account-service-panel="manage-service-panel" data-account-service-focus="manage-inline-email" data-shell-target="services"><span class="service-card-kicker">Billing</span><h3>Manage subscriptions</h3><p>Open Stripe billing access for existing self-hosted subscriptions without leaving the Pulse Account shell.</p></button><button class="service-card service-card-button" type="button" id="open-retrieve-service" data-account-service-action="open-service-panel" data-account-service-panel="retrieve-service-panel" data-account-service-focus="retrieve-inline-email" data-shell-target="services"><span class="service-card-kicker">Licenses</span><h3>Retrieve licenses</h3><p>Recover the latest active self-hosted license and invoice link for a commercial email address.</p></button><button class="service-card service-card-button" type="button" id="open-refund-service" data-account-service-action="open-service-panel" data-account-service-panel="refund-service-panel" data-account-service-focus="refund-inline-email" data-shell-target="services"><span class="service-card-kicker">Refunds</span><h3>Refund requests</h3><p>Request an immediate self-serve refund for eligible self-hosted purchases with explicit revocation confirmation.</p></button><button class="service-card service-card-button" type="button" id="open-data-service" data-account-service-action="open-service-panel" data-account-service-panel="data-service-panel" data-account-service-focus="data-export-email" data-shell-target="services"><span class="service-card-kicker">Privacy</span><h3>Data and privacy</h3><p>Request commercial data export or deletion without leaving the account shell.</p></button></div><div class="service-panel" id="manage-service-panel"><div id="manage-service-root"></div></div><div class="service-panel" id="retrieve-service-panel"><div id="retrieve-service-root"></div></div><div class="service-panel" id="refund-service-panel"><div id="refund-service-root"></div></div><div class="service-panel" id="data-service-panel"><h3>Data and privacy</h3><p>Request export or deletion of the commercial data tied to an email address. Payment data held directly by Stripe still requires support handling.</p><div class="subsection"><div id="data-export-root"></div></div><div class="subsection"><div id="data-delete-root"></div></div><div class="helper-text">Payment-card data stays with Stripe. For Stripe deletion support, contact <a href="mailto:' + escapeAttr(context.bootstrap.support_email || "") + '">' + escapeHTML(context.bootstrap.support_email || "") + '</a>.</div></div></section><section class="portal-content-panel portal-content-panel-support">' + renderSupportSection(context) + "</section></div></div></div>";
|
||||
return '<div class="portal-shell" data-shell-section="' + activeSection + '">' + renderOverviewBand(accounts) + '<div class="portal-shell-layout">' + renderShellNavigation(accounts, context.bootstrap.support_email || "", activeSection) + '<div class="portal-shell-main"><section class="portal-content-panel portal-content-panel-overview"><div class="portal-top-section-header"><div class="account-panel-kicker">' + (hosted ? "Hosted operations" : "Hosted access") + "</div><h2>" + (hosted ? "Hosted operations" : "No hosted workspaces are attached yet") + "</h2><p>" + (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.") + '</p></div><div id="accounts-root">' + hostedContent + '</div></section><section class="portal-content-panel portal-content-panel-services service-section" id="account-services-section"><div class="service-header"><div><div class="account-panel-kicker">Account services</div><h2>' + serviceHeading + '</h2></div><div class="service-note">' + serviceNote + '</div></div><div class="service-action-list">' + 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") + '</div><div class="service-panel" id="manage-service-panel"><div id="manage-service-root"></div></div><div class="service-panel" id="retrieve-service-panel"><div id="retrieve-service-root"></div></div><div class="service-panel" id="refund-service-panel"><div id="refund-service-root"></div></div><div class="service-panel" id="data-service-panel"><h3>Data and privacy</h3><p>Request export or deletion of the commercial data tied to an email address. Payment data held directly by Stripe still requires support handling.</p><div class="subsection"><div id="data-export-root"></div></div><div class="subsection"><div id="data-delete-root"></div></div><div class="helper-text">Payment-card data stays with Stripe. For Stripe deletion support, contact <a href="mailto:' + escapeAttr(context.bootstrap.support_email || "") + '">' + escapeHTML(context.bootstrap.support_email || "") + '</a>.</div></div></section><section class="portal-content-panel portal-content-panel-support">' + renderSupportSection(context) + "</section></div></div></div>";
|
||||
}
|
||||
function renderSignedOutPortalHTML(context) {
|
||||
var statusHTML = "";
|
||||
|
||||
@@ -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 =
|
||||
'<span>Operator</span>' +
|
||||
'<span>Access</span>' +
|
||||
'<span>Controls</span>';
|
||||
container.appendChild(head);
|
||||
}
|
||||
|
||||
export function renderAddWorkspaceSection(accountID: string, entry: PortalAccountUIEntry): void {
|
||||
var form = getElement<HTMLElement>('add-ws-form-' + accountID);
|
||||
var spinner = getElement<HTMLElement>('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));
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
|
||||
@@ -102,6 +102,28 @@ function healthBadgeHTML(workspace: PortalWorkspaceSummary): string {
|
||||
return '<span class="badge badge-checking">Checking</span>';
|
||||
}
|
||||
|
||||
function renderServiceActionRow(
|
||||
id: string,
|
||||
kicker: string,
|
||||
title: string,
|
||||
description: string,
|
||||
panelID: string,
|
||||
focusID: string
|
||||
): string {
|
||||
return (
|
||||
'<div class="service-action-row">' +
|
||||
'<div class="service-action-copy">' +
|
||||
'<span class="service-card-kicker">' + kicker + '</span>' +
|
||||
'<h3>' + title + '</h3>' +
|
||||
'<p>' + description + '</p>' +
|
||||
'</div>' +
|
||||
'<div class="service-action-cta">' +
|
||||
'<button class="btn-secondary service-action-button" type="button" id="' + id + '" data-account-service-action="open-service-panel" data-account-service-panel="' + panelID + '" data-account-service-focus="' + focusID + '" data-shell-target="services">Open</button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
? '<div class="workspace-list">' + workspaces.map(function(workspace) {
|
||||
? '<div class="workspace-list-wrap">' +
|
||||
'<div class="workspace-list-toolbar">' +
|
||||
'<div class="workspace-list-summary">Review the hosted fleet, open a workspace, and keep lifecycle actions explicit.</div>' +
|
||||
'<div class="workspace-list-stats">' +
|
||||
'<span class="workspace-list-stat"><strong>' + String(workspaces.length) + '</strong> total</span>' +
|
||||
'<span class="workspace-list-stat"><strong>' + String(healthyCount) + '</strong> healthy</span>' +
|
||||
'<span class="workspace-list-stat"><strong>' + String(checkingCount) + '</strong> checking</span>' +
|
||||
'<span class="workspace-list-stat workspace-list-stat-attention"><strong>' + String(unhealthyCount) + '</strong> needs attention</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="workspace-list-head">' +
|
||||
'<span>Workspace</span>' +
|
||||
'<span>Health</span>' +
|
||||
'<span>Lifecycle</span>' +
|
||||
'<span>Actions</span>' +
|
||||
'</div>' +
|
||||
'<div class="workspace-list">' + workspaces.map(function(workspace) {
|
||||
return renderWorkspaceCard(account, workspace, accountAPIBasePath);
|
||||
}).join('') + '</div>'
|
||||
}).join('') + '</div>' +
|
||||
'</div>'
|
||||
: '<div class="empty-state"><p>No hosted workspaces yet. Create one to get started.</p></div>';
|
||||
|
||||
return (
|
||||
@@ -398,8 +440,8 @@ function renderAccountWorkspaceSection(account: PortalAccountSummary, accountAPI
|
||||
'<div class="account-stage-header">' +
|
||||
'<div>' +
|
||||
'<div class="account-panel-kicker">Workspace fleet</div>' +
|
||||
'<h3>Open hosted Pulse workspaces</h3>' +
|
||||
'<p>Review fleet health, move into a workspace, and keep lifecycle actions explicit.</p>' +
|
||||
'<h3>Hosted fleet</h3>' +
|
||||
'<p>Use the fleet view to open workspaces, watch health posture, and keep management actions explicit.</p>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
workspaceHTML +
|
||||
@@ -562,7 +604,7 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string
|
||||
'<section class="portal-content-panel portal-content-panel-overview">' +
|
||||
'<div class="portal-top-section-header">' +
|
||||
'<div class="account-panel-kicker">' + (hosted ? 'Hosted operations' : 'Hosted access') + '</div>' +
|
||||
'<h2>' + (hosted ? 'Run hosted accounts from one place' : 'No hosted workspaces are attached yet') + '</h2>' +
|
||||
'<h2>' + (hosted ? 'Hosted operations' : 'No hosted workspaces are attached yet') + '</h2>' +
|
||||
'<p>' + (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.') + '</p>' +
|
||||
@@ -577,27 +619,11 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string
|
||||
'</div>' +
|
||||
'<div class="service-note">' + serviceNote + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="service-grid">' +
|
||||
'<button class="service-card service-card-button" type="button" id="open-manage-service" data-account-service-action="open-service-panel" data-account-service-panel="manage-service-panel" data-account-service-focus="manage-inline-email" data-shell-target="services">' +
|
||||
'<span class="service-card-kicker">Billing</span>' +
|
||||
'<h3>Manage subscriptions</h3>' +
|
||||
'<p>Open Stripe billing access for existing self-hosted subscriptions without leaving the Pulse Account shell.</p>' +
|
||||
'</button>' +
|
||||
'<button class="service-card service-card-button" type="button" id="open-retrieve-service" data-account-service-action="open-service-panel" data-account-service-panel="retrieve-service-panel" data-account-service-focus="retrieve-inline-email" data-shell-target="services">' +
|
||||
'<span class="service-card-kicker">Licenses</span>' +
|
||||
'<h3>Retrieve licenses</h3>' +
|
||||
'<p>Recover the latest active self-hosted license and invoice link for a commercial email address.</p>' +
|
||||
'</button>' +
|
||||
'<button class="service-card service-card-button" type="button" id="open-refund-service" data-account-service-action="open-service-panel" data-account-service-panel="refund-service-panel" data-account-service-focus="refund-inline-email" data-shell-target="services">' +
|
||||
'<span class="service-card-kicker">Refunds</span>' +
|
||||
'<h3>Refund requests</h3>' +
|
||||
'<p>Request an immediate self-serve refund for eligible self-hosted purchases with explicit revocation confirmation.</p>' +
|
||||
'</button>' +
|
||||
'<button class="service-card service-card-button" type="button" id="open-data-service" data-account-service-action="open-service-panel" data-account-service-panel="data-service-panel" data-account-service-focus="data-export-email" data-shell-target="services">' +
|
||||
'<span class="service-card-kicker">Privacy</span>' +
|
||||
'<h3>Data and privacy</h3>' +
|
||||
'<p>Request commercial data export or deletion without leaving the account shell.</p>' +
|
||||
'</button>' +
|
||||
'<div class="service-action-list">' +
|
||||
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') +
|
||||
'</div>' +
|
||||
'<div class="service-panel" id="manage-service-panel"><div id="manage-service-root"></div></div>' +
|
||||
'<div class="service-panel" id="retrieve-service-panel"><div id="retrieve-service-root"></div></div>' +
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user