mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
feat(portal): add hosted account operations surface
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Pulse Account Portal Spec
|
||||
|
||||
Last updated: 2026-03-26
|
||||
Last updated: 2026-03-27
|
||||
Status: ACTIVE
|
||||
|
||||
## Purpose
|
||||
@@ -135,6 +135,11 @@ Core rules:
|
||||
7. Self-hosted billing/license tools belong in the same account shell, but
|
||||
they must present as a secondary account-services area when hosted access
|
||||
is active.
|
||||
8. Hosted and MSP accounts must expose account-level operations separately from
|
||||
individual workspace cards so operator actions are visible before a user
|
||||
starts drilling into one workspace at a time.
|
||||
9. Workspace fleets must summarize health and attention state at the account
|
||||
level, not force the user to scan badges one card at a time.
|
||||
|
||||
## Screen Model
|
||||
|
||||
@@ -158,9 +163,14 @@ The signed-in shell should be treated as four first-class states:
|
||||
Each signed-in state should render:
|
||||
|
||||
1. a concise overview band that summarizes the account posture
|
||||
2. account cards with role, account kind, and workspace totals
|
||||
3. explicit action groups, not anonymous menu affordances
|
||||
4. a clearly separated `Self-hosted licenses and billing` section when those
|
||||
2. account cards with role, account kind, workspace totals, and account-level
|
||||
fleet status
|
||||
3. an explicit `Account operations` area for billing, team, and workspace
|
||||
management entry points
|
||||
4. a `Workspace fleet` area that makes health and lifecycle state legible
|
||||
before the user opens a workspace
|
||||
5. explicit action groups, not anonymous menu affordances
|
||||
6. a clearly separated `Self-hosted licenses and billing` section when those
|
||||
tools are present below hosted content
|
||||
|
||||
## Product-Specific Boundaries
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"source_hash": "ade0e3202886602f0f46ca656041930d213b8dc6ea3d681664a6596f18da0c12",
|
||||
"source_hash": "f091b6dbbdf775a750bbba5c4047fbba2553fa0393420916e2e9e5ca315e1437",
|
||||
"build_inputs": [
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
|
||||
+91
-2
@@ -110,6 +110,43 @@ header .logout-btn:hover {
|
||||
color: #64748b;
|
||||
max-width: 520px;
|
||||
}
|
||||
.account-status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.account-stat-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, .04);
|
||||
}
|
||||
.account-stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
.account-stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
.account-stat-healthy {
|
||||
color: #166534;
|
||||
}
|
||||
.account-stat-checking {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.account-stat-unhealthy {
|
||||
color: #b91c1c;
|
||||
}
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -194,10 +231,16 @@ header .logout-btn:hover {
|
||||
.ws-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.ws-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
}
|
||||
.ws-name {
|
||||
font-weight: 600;
|
||||
@@ -209,6 +252,11 @@ header .logout-btn:hover {
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ws-summary {
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.ws-created {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
@@ -305,10 +353,39 @@ header .logout-btn:hover {
|
||||
.link-button {
|
||||
text-decoration: none;
|
||||
}
|
||||
.account-operations-panel {
|
||||
margin: 0 0 18px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 18px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, .04);
|
||||
}
|
||||
.account-operations-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: 540px;
|
||||
}
|
||||
.account-operations-copy h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.account-operations-copy p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.account-actions {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -794,6 +871,15 @@ header .logout-btn:hover {
|
||||
.account-badges {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.account-status-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.account-operations-panel {
|
||||
flex-direction: column;
|
||||
}
|
||||
.account-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.account-subsection-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
@@ -803,6 +889,9 @@ header .logout-btn:hover {
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.account-status-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.workspace-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
+23
-3
@@ -1464,6 +1464,20 @@
|
||||
function hasHostedAccounts(accounts) {
|
||||
return accounts.length > 0;
|
||||
}
|
||||
function countWorkspacesByHealth(workspaces, status) {
|
||||
var count = 0;
|
||||
for (var i = 0; i < workspaces.length; i += 1) {
|
||||
if (String(workspaces[i].health_status || "") === status) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
function countWorkspacesByState(workspaces, state) {
|
||||
var count = 0;
|
||||
for (var i = 0; i < workspaces.length; i += 1) {
|
||||
if (String(workspaces[i].state || "") === state) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
function renderOverviewBand(context, accounts) {
|
||||
var hosted = hasHostedAccounts(accounts);
|
||||
var workspaceTotal = countWorkspaces(accounts);
|
||||
@@ -1478,6 +1492,7 @@
|
||||
var state = String(workspace.state || "");
|
||||
var safeState = escapeHTML(state);
|
||||
var createdLabel = formatWorkspaceDate(workspace.created_at);
|
||||
var healthSummary = workspace.health_status === "healthy" ? "Live updates and health checks are currently good." : workspace.health_status === "unhealthy" ? "This workspace needs attention before it is trustworthy." : "This workspace is still waiting on a completed health check.";
|
||||
var openAction = "";
|
||||
if (state === "active") {
|
||||
openAction = '<form method="POST" action="' + escapeAttr(accountAPIBasePath + "/" + account.id + "/tenants/" + workspace.id + "/handoff") + '"><button type="submit" class="btn-primary">Open workspace</button></form>';
|
||||
@@ -1491,11 +1506,16 @@
|
||||
manageAction = '<div class="workspace-menu-wrap"><button type="button" class="btn-secondary btn-workspace-menu" id="workspace-menu-button-' + escapeAttr(account.id) + "-" + escapeAttr(workspace.id) + '" data-action="toggle-workspace-menu" data-account-id="' + escapeAttr(account.id) + '" data-workspace-id="' + escapeAttr(workspace.id) + '" aria-haspopup="menu" aria-expanded="false">Manage</button><div class="workspace-menu" id="workspace-menu-' + escapeAttr(account.id) + "-" + escapeAttr(workspace.id) + '" data-workspace-menu-account-id="' + escapeAttr(account.id) + '" data-workspace-id="' + escapeAttr(workspace.id) + '" role="menu" hidden><button type="button" class="workspace-menu-item workspace-menu-item-danger" data-action="workspace-action" data-account-id="' + escapeAttr(account.id) + '" data-workspace-id="' + escapeAttr(workspace.id) + '" data-workspace-action="' + escapeAttr(menuAction) + '" data-workspace-name="' + escapeAttr(workspace.display_name) + '">' + escapeHTML(menuLabel) + "</button></div></div>";
|
||||
}
|
||||
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">' + healthBadgeHTML(workspace) + '<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"><div class="ws-title-row"><span class="ws-name">' + escapeHTML(workspace.display_name) + '</span></div><div class="ws-meta">' + healthBadgeHTML(workspace) + '<span class="badge badge-' + safeState + '">' + safeState + "</span>" + createdMeta + '</div><div class="ws-summary">' + escapeHTML(healthSummary) + '</div></div><div class="ws-actions">' + openAction + manageAction + "</div></div>";
|
||||
}
|
||||
function renderAccountSection(account, accountAPIBasePath) {
|
||||
var workspaces = Array.isArray(account.workspaces) ? account.workspaces : [];
|
||||
var summaryText = accountKindLabel(account) + " \xB7 " + titleCase(account.role) + " \xB7 " + workspaceCountLabel(workspaces.length);
|
||||
var healthyCount = countWorkspacesByHealth(workspaces, "healthy");
|
||||
var checkingCount = countWorkspacesByHealth(workspaces, "checking");
|
||||
var unhealthyCount = countWorkspacesByHealth(workspaces, "unhealthy");
|
||||
var activeCount = countWorkspacesByState(workspaces, "active");
|
||||
var operationsCopy = account.kind === "msp" ? "Manage the client fleet from this account surface. Workspace creation, billing, and team actions belong here." : "Use this account surface to open hosted workspaces, manage billing, and control access for this hosted account.";
|
||||
var workspaceHTML = "";
|
||||
if (workspaces.length === 0) {
|
||||
workspaceHTML = '<div class="empty-state"><p>No hosted workspaces yet. Create one to get started.</p></div>';
|
||||
@@ -1508,13 +1528,13 @@
|
||||
var teamSection = "";
|
||||
var addWorkspaceForm = "";
|
||||
if (account.can_manage) {
|
||||
actions = '<div class="account-actions">' + (account.kind === "msp" ? '<button type="button" class="btn-secondary" id="add-ws-btn-' + escapeAttr(account.id) + '" data-action="toggle-add-workspace" data-account-id="' + escapeAttr(account.id) + '">+ Add workspace</button>' : "") + (account.has_billing ? '<button type="button" class="btn-secondary" data-action="open-billing" data-account-id="' + escapeAttr(account.id) + '">Manage billing</button>' : "") + '<button type="button" class="btn-secondary" id="team-btn-' + escapeAttr(account.id) + '" data-action="toggle-team" data-account-id="' + escapeAttr(account.id) + '">Manage team</button></div>';
|
||||
actions = '<div class="account-operations-panel"><div class="account-operations-copy"><h3>Account operations</h3><p>' + escapeHTML(operationsCopy) + '</p></div><div class="account-actions">' + (account.kind === "msp" ? '<button type="button" class="btn-secondary" id="add-ws-btn-' + escapeAttr(account.id) + '" data-action="toggle-add-workspace" data-account-id="' + escapeAttr(account.id) + '">Add workspace</button>' : "") + (account.has_billing ? '<button type="button" class="btn-secondary" data-action="open-billing" data-account-id="' + escapeAttr(account.id) + '">Manage billing</button>' : "") + '<button type="button" class="btn-secondary" id="team-btn-' + escapeAttr(account.id) + '" data-action="toggle-team" data-account-id="' + escapeAttr(account.id) + '">Manage team</button></div></div>';
|
||||
teamSection = '<div class="team-section" id="team-section-' + escapeAttr(account.id) + '" data-actor-role="' + escapeAttr(account.role) + '"><h3>Team members</h3><table class="team-table"><thead><tr><th>Email</th><th>Role</th><th></th></tr></thead><tbody id="team-list-' + escapeAttr(account.id) + '"><tr><td colspan="3" class="team-message-cell">Loading\u2026</td></tr></tbody></table><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>';
|
||||
if (account.kind === "msp") {
|
||||
addWorkspaceForm = '<div class="add-workspace-form" id="add-ws-form-' + escapeAttr(account.id) + '"><label for="ws-name-' + escapeAttr(account.id) + '">Workspace name (e.g. client name)</label><input type="text" id="ws-name-' + escapeAttr(account.id) + '" placeholder="Acme Corp" maxlength="80" autocomplete="off"><div class="form-actions"><button type="button" class="btn-primary" data-action="create-workspace" data-account-id="' + escapeAttr(account.id) + '">Create workspace</button><button type="button" class="btn-secondary" data-action="toggle-add-workspace" data-account-id="' + escapeAttr(account.id) + '">Cancel</button><div class="spinner" id="ws-spinner-' + escapeAttr(account.id) + '" hidden></div></div></div>';
|
||||
}
|
||||
}
|
||||
return '<section class="account-section"><div class="account-header"><div class="account-heading"><div class="account-eyebrow">' + escapeHTML(accountKindLabel(account)) + "</div><h2>" + escapeHTML(account.name) + '</h2><div class="account-summary">' + escapeHTML(summaryText) + '</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-subsection-header"><h3>Workspaces</h3><p>Open hosted Pulse workspaces and manage lifecycle actions for this account.</p></div>' + workspaceHTML + actions + teamSection + addWorkspaceForm + "</section>";
|
||||
return '<section class="account-section"><div class="account-header"><div class="account-heading"><div class="account-eyebrow">' + escapeHTML(accountKindLabel(account)) + "</div><h2>" + escapeHTML(account.name) + '</h2><div class="account-summary">' + escapeHTML(summaryText) + '</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-status-grid"><div class="account-stat-card"><span class="account-stat-label">Active workspaces</span><span class="account-stat-value">' + String(activeCount) + '</span></div><div class="account-stat-card"><span class="account-stat-label">Healthy</span><span class="account-stat-value account-stat-healthy">' + String(healthyCount) + '</span></div><div class="account-stat-card"><span class="account-stat-label">Checking</span><span class="account-stat-value account-stat-checking">' + String(checkingCount) + '</span></div><div class="account-stat-card"><span class="account-stat-label">Needs attention</span><span class="account-stat-value account-stat-unhealthy">' + String(unhealthyCount) + "</span></div></div>" + actions + '<div class="account-subsection-header"><h3>Workspace fleet</h3><p>Open hosted Pulse workspaces, review fleet health, and manage lifecycle actions for this account.</p></div>' + workspaceHTML + teamSection + addWorkspaceForm + "</section>";
|
||||
}
|
||||
function renderHeaderHTML(context) {
|
||||
if (context.bootstrap.authenticated) {
|
||||
|
||||
@@ -131,12 +131,20 @@ describe('shell view', function() {
|
||||
expect(html).toContain('MSP account');
|
||||
expect(html).toContain('MSP account · Owner · 3 workspaces');
|
||||
expect(html).toContain('Acme MSP');
|
||||
expect(html).toContain('Account operations');
|
||||
expect(html).toContain('Manage the client fleet from this account surface.');
|
||||
expect(html).toContain('Workspace fleet');
|
||||
expect(html).toContain('Alpha Workspace');
|
||||
expect(html).toContain('Beta Workspace');
|
||||
expect(html).toContain('Gamma Workspace');
|
||||
expect(html).toContain('Active workspaces');
|
||||
expect(html).toContain('Needs attention');
|
||||
expect(html).toContain('Healthy</span>');
|
||||
expect(html).toContain('Unhealthy</span>');
|
||||
expect(html).toContain('Checking</span>');
|
||||
expect(html).toContain('Live updates and health checks are currently good.');
|
||||
expect(html).toContain('This workspace needs attention before it is trustworthy.');
|
||||
expect(html).toContain('This workspace is still waiting on a completed health check.');
|
||||
expect(html).toContain('/api/accounts/acct_1/tenants/ws_active/handoff');
|
||||
expect(html).toContain('Open workspace');
|
||||
expect(html).toContain('data-action="toggle-workspace-menu"');
|
||||
|
||||
@@ -77,6 +77,22 @@ function hasHostedAccounts(accounts: PortalAccountSummary[]): boolean {
|
||||
return accounts.length > 0;
|
||||
}
|
||||
|
||||
function countWorkspacesByHealth(workspaces: PortalWorkspaceSummary[], status: string): number {
|
||||
var count = 0;
|
||||
for (var i = 0; i < workspaces.length; i += 1) {
|
||||
if (String(workspaces[i].health_status || '') === status) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function countWorkspacesByState(workspaces: PortalWorkspaceSummary[], state: string): number {
|
||||
var count = 0;
|
||||
for (var i = 0; i < workspaces.length; i += 1) {
|
||||
if (String(workspaces[i].state || '') === state) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function renderOverviewBand(context: ShellViewContext, accounts: PortalAccountSummary[]): string {
|
||||
var hosted = hasHostedAccounts(accounts);
|
||||
var workspaceTotal = countWorkspaces(accounts);
|
||||
@@ -117,6 +133,11 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor
|
||||
var state = String(workspace.state || '');
|
||||
var safeState = escapeHTML(state);
|
||||
var createdLabel = formatWorkspaceDate(workspace.created_at);
|
||||
var healthSummary = workspace.health_status === 'healthy'
|
||||
? 'Live updates and health checks are currently good.'
|
||||
: workspace.health_status === 'unhealthy'
|
||||
? 'This workspace needs attention before it is trustworthy.'
|
||||
: 'This workspace is still waiting on a completed health check.';
|
||||
var openAction = '';
|
||||
if (state === 'active') {
|
||||
openAction =
|
||||
@@ -172,12 +193,15 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor
|
||||
return (
|
||||
'<div class="workspace-card">' +
|
||||
'<div class="ws-info">' +
|
||||
'<span class="ws-name">' + escapeHTML(workspace.display_name) + '</span>' +
|
||||
'<div class="ws-title-row">' +
|
||||
'<span class="ws-name">' + escapeHTML(workspace.display_name) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="ws-meta">' +
|
||||
healthBadgeHTML(workspace) +
|
||||
'<span class="badge badge-' + safeState + '">' + safeState + '</span>' +
|
||||
createdMeta +
|
||||
'</div>' +
|
||||
'<div class="ws-summary">' + escapeHTML(healthSummary) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="ws-actions">' +
|
||||
openAction +
|
||||
@@ -190,6 +214,13 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor
|
||||
function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath: string): string {
|
||||
var workspaces = Array.isArray(account.workspaces) ? account.workspaces : [];
|
||||
var summaryText = accountKindLabel(account) + ' · ' + titleCase(account.role) + ' · ' + workspaceCountLabel(workspaces.length);
|
||||
var healthyCount = countWorkspacesByHealth(workspaces, 'healthy');
|
||||
var checkingCount = countWorkspacesByHealth(workspaces, 'checking');
|
||||
var unhealthyCount = countWorkspacesByHealth(workspaces, 'unhealthy');
|
||||
var activeCount = countWorkspacesByState(workspaces, 'active');
|
||||
var operationsCopy = account.kind === 'msp'
|
||||
? 'Manage the client fleet from this account surface. Workspace creation, billing, and team actions belong here.'
|
||||
: 'Use this account surface to open hosted workspaces, manage billing, and control access for this hosted account.';
|
||||
var workspaceHTML = '';
|
||||
if (workspaces.length === 0) {
|
||||
workspaceHTML = '<div class="empty-state"><p>No hosted workspaces yet. Create one to get started.</p></div>';
|
||||
@@ -207,13 +238,18 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath:
|
||||
var addWorkspaceForm = '';
|
||||
if (account.can_manage) {
|
||||
actions =
|
||||
'<div class="account-operations-panel">' +
|
||||
'<div class="account-operations-copy">' +
|
||||
'<h3>Account operations</h3>' +
|
||||
'<p>' + escapeHTML(operationsCopy) + '</p>' +
|
||||
'</div>' +
|
||||
'<div class="account-actions">' +
|
||||
(account.kind === 'msp'
|
||||
? '<button type="button" class="btn-secondary" id="add-ws-btn-' +
|
||||
escapeAttr(account.id) +
|
||||
'" data-action="toggle-add-workspace" data-account-id="' +
|
||||
escapeAttr(account.id) +
|
||||
'">+ Add workspace</button>'
|
||||
'">Add workspace</button>'
|
||||
: '') +
|
||||
(account.has_billing
|
||||
? '<button type="button" class="btn-secondary" data-action="open-billing" data-account-id="' +
|
||||
@@ -225,6 +261,7 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath:
|
||||
'" data-action="toggle-team" data-account-id="' +
|
||||
escapeAttr(account.id) +
|
||||
'">Manage team</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
teamSection =
|
||||
@@ -298,12 +335,30 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath:
|
||||
roleBadgeHTML(account.role) +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="account-status-grid">' +
|
||||
'<div class="account-stat-card">' +
|
||||
'<span class="account-stat-label">Active workspaces</span>' +
|
||||
'<span class="account-stat-value">' + String(activeCount) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="account-stat-card">' +
|
||||
'<span class="account-stat-label">Healthy</span>' +
|
||||
'<span class="account-stat-value account-stat-healthy">' + String(healthyCount) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="account-stat-card">' +
|
||||
'<span class="account-stat-label">Checking</span>' +
|
||||
'<span class="account-stat-value account-stat-checking">' + String(checkingCount) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="account-stat-card">' +
|
||||
'<span class="account-stat-label">Needs attention</span>' +
|
||||
'<span class="account-stat-value account-stat-unhealthy">' + String(unhealthyCount) + '</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
actions +
|
||||
'<div class="account-subsection-header">' +
|
||||
'<h3>Workspaces</h3>' +
|
||||
'<p>Open hosted Pulse workspaces and manage lifecycle actions for this account.</p>' +
|
||||
'<h3>Workspace fleet</h3>' +
|
||||
'<p>Open hosted Pulse workspaces, review fleet health, and manage lifecycle actions for this account.</p>' +
|
||||
'</div>' +
|
||||
workspaceHTML +
|
||||
actions +
|
||||
teamSection +
|
||||
addWorkspaceForm +
|
||||
'</section>'
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
.account-subsection-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin: 0 0 14px; }
|
||||
.account-subsection-header h3 { margin: 0; font-size: 15px; font-weight: 700; }
|
||||
.account-subsection-header p { margin: 0; font-size: 13px; color: #64748b; max-width: 520px; }
|
||||
.account-status-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 0 0 16px; }
|
||||
.account-stat-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 14px 16px; display: flex; flex-direction: column; gap: 6px; box-shadow: 0 1px 3px rgba(15,23,42,.04); }
|
||||
.account-stat-label { font-size: 12px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: #64748b; }
|
||||
.account-stat-value { font-size: 24px; font-weight: 700; color: #0f172a; }
|
||||
.account-stat-healthy { color: #166534; }
|
||||
.account-stat-checking { color: #1d4ed8; }
|
||||
.account-stat-unhealthy { color: #b91c1c; }
|
||||
.badge { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.badge-msp { background: #dbeafe; color: #1e40af; }
|
||||
.badge-cloud { background: #dcfce7; color: #166534; }
|
||||
@@ -33,9 +40,11 @@
|
||||
.workspace-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.workspace-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 10px; padding: 14px 18px; display: flex; align-items: center; justify-content: space-between; gap: 12px; box-shadow: 0 1px 3px rgba(15,23,42,.04); }
|
||||
.workspace-card:hover { border-color: #cbd5e1; }
|
||||
.ws-info { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.ws-info { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
.ws-title-row { display: flex; align-items: center; gap: 10px; width: 100%; }
|
||||
.ws-name { font-weight: 600; font-size: 15px; }
|
||||
.ws-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.ws-summary { font-size: 13px; color: #475569; line-height: 1.45; }
|
||||
.ws-created { font-size: 11px; color: #94a3b8; }
|
||||
.ws-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
.workspace-menu-wrap { position: relative; }
|
||||
@@ -53,7 +62,11 @@
|
||||
.btn-danger:hover { background: #fef2f2; }
|
||||
.btn-compact { padding: 8px 14px; font-size: 13px; }
|
||||
.link-button { text-decoration: none; }
|
||||
.account-actions { margin-top: 14px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.account-operations-panel { margin: 0 0 18px; display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 16px 18px; background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; box-shadow: 0 1px 3px rgba(15,23,42,.04); }
|
||||
.account-operations-copy { display: flex; flex-direction: column; gap: 4px; max-width: 540px; }
|
||||
.account-operations-copy h3 { margin: 0; font-size: 15px; font-weight: 700; }
|
||||
.account-operations-copy p { margin: 0; font-size: 13px; color: #64748b; line-height: 1.5; }
|
||||
.account-actions { display: flex; align-items: center; justify-content: flex-end; gap: 12px; flex-wrap: wrap; }
|
||||
.add-workspace-form { margin-top: 12px; display: none; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px; }
|
||||
.add-workspace-form.visible { display: block; }
|
||||
.add-workspace-form label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; }
|
||||
@@ -141,10 +154,14 @@
|
||||
@media (max-width: 720px) {
|
||||
.account-header { flex-direction: column; }
|
||||
.account-badges { justify-content: flex-start; }
|
||||
.account-status-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.account-operations-panel { flex-direction: column; }
|
||||
.account-actions { justify-content: flex-start; }
|
||||
.account-subsection-header { flex-direction: column; align-items: flex-start; }
|
||||
.overview-stats { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.account-status-grid { grid-template-columns: 1fr; }
|
||||
.workspace-card { flex-direction: column; align-items: flex-start; }
|
||||
.ws-actions { align-self: stretch; }
|
||||
.ws-actions form, .ws-actions .btn-primary { width: 100%; text-align: center; }
|
||||
|
||||
Reference in New Issue
Block a user