From d77e959fb9fb3ded40fdf33741ad978663c3010a Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 29 Apr 2026 23:35:40 +0100 Subject: [PATCH] Canonicalize settings data grid frames --- .../v6/internal/subsystems/agent-lifecycle.md | 5 + .../v6/internal/subsystems/api-contracts.md | 4 + .../subsystems/frontend-primitives.md | 6 +- .../subsystems/organization-settings.md | 5 + .../v6/internal/subsystems/registry.json | 6 +- .../internal/subsystems/security-privacy.md | 4 + .../components/Settings/APITokenManager.tsx | 279 +++++++++-------- .../Settings/AgentProfilesPanel.tsx | 283 +++++++++--------- .../OrganizationAccessMembersSection.tsx | 171 ++++++----- .../OrganizationIncomingSharesSection.tsx | 186 ++++++------ .../OrganizationOutgoingSharesSection.tsx | 176 ++++++----- .../OrganizationOverviewMembersSection.tsx | 66 ++-- .../src/components/Settings/RolesPanel.tsx | 136 +++++---- .../Settings/UserAssignmentsPanel.tsx | 102 ++++--- .../src/components/shared/PulseDataGrid.tsx | 4 +- .../SharedPrimitives.guardrails.test.ts | 30 ++ .../shared/__tests__/PulseDataGrid.test.tsx | 27 ++ .../components/shared/pulseDataGridModel.ts | 13 + 18 files changed, 790 insertions(+), 713 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 1eddc1fd2..bb23d53cf 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -158,6 +158,11 @@ credential, capability, status, and action cells, but table scroll framing must stay on the shared `Table` primitive. If a configured-node table needs bounded vertical height, apply it through `Table` `wrapperClass` instead of an outer lifecycle-local scroll container. +Agent profile management tables follow that same presentation boundary: +`frontend-modern/src/components/Settings/AgentProfilesPanel.tsx` may own agent +profile and assignment columns, but embedded table framing must route through +`PulseDataGrid`'s shared frame variants instead of lifecycle-local +`overflow-x-auto` or side-border wrappers. ## Extension Points diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index f9fed41d4..efafacfe0 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -97,6 +97,10 @@ Own canonical runtime payload shapes between backend and frontend. 8. `frontend-modern/src/api/security.ts` shared with `security-privacy`: the security frontend client is both a security/privacy control surface and a canonical API payload contract boundary. 9. `frontend-modern/src/api/updates.ts` shared with `deployment-installability`: the updates frontend client is both a deployment-installability control surface and a canonical API payload contract boundary. 10. `frontend-modern/src/components/Settings/APITokenManager.tsx` shared with `security-privacy`: the API token settings surface is both a security/privacy control surface and a canonical API payload contract boundary. + The API token inventory table may own credential and usage cells, but it + must inherit embedded table framing from `frontend-primitives` + `PulseDataGrid` rather than carrying API-token-local scroll or border + wrappers around the grid. 11. `frontend-modern/src/components/Settings/apiTokenManagerModel.ts` shared with `security-privacy`: the pure API token settings model is both a security/privacy control surface and a canonical API payload contract boundary. 12. `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx` shared with `agent-lifecycle`: the inline node credential slot is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary. 13. `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx` shared with `agent-lifecycle`: the pure infrastructure operations inventory/install model is both an agent fleet lifecycle control surface and an API token, lookup, assignment, and reporting/install contract boundary. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 62913054b..cb7205a38 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -195,7 +195,11 @@ work extends shared components instead of creating new local variants. tables, Ceph tables, PMG resource panels, and `PulseDataGrid`, must follow the same rule: feature owners may pass `wrapperClass` for bounded height, border, radius, or scrollbar hiding, but they must not add raw table markup - or local scroll wrappers around the shared table primitive. + or local scroll wrappers around the shared table primitive. `PulseDataGrid` + also owns its root frame variants: feature surfaces embedded directly inside + an existing panel/card frame must use the shared `frame="flush"` mode rather + than caller-local border overrides, horizontal-scroll wrappers, or negative + margin compensation. Product-table subgroup/header rows must likewise consume the shared `frontend-modern/src/components/shared/groupedTableRowPresentation.ts` helper and `.grouped-table-row` CSS token contract instead of local diff --git a/docs/release-control/v6/internal/subsystems/organization-settings.md b/docs/release-control/v6/internal/subsystems/organization-settings.md index 4abef01fa..727750091 100644 --- a/docs/release-control/v6/internal/subsystems/organization-settings.md +++ b/docs/release-control/v6/internal/subsystems/organization-settings.md @@ -71,6 +71,11 @@ create, review, and approve cross-organization shares. 1. Add or change organization role and share semantics through `internal/models/organization.go` 2. Add or change organization access, overview, sharing, RBAC feature-gating, role-management, or user-assignment presentation through `frontend-modern/src/components/Settings/OrganizationAccessPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationAccessLoadingState.tsx`, `frontend-modern/src/components/Settings/OrganizationAccessManagementSection.tsx`, `frontend-modern/src/components/Settings/OrganizationAccessInvitationsSection.tsx`, `frontend-modern/src/components/Settings/OrganizationAccessMembersSection.tsx`, `frontend-modern/src/components/Settings/OrganizationOverviewPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationOverviewLoadingState.tsx`, `frontend-modern/src/components/Settings/OrganizationOverviewDetailsSection.tsx`, `frontend-modern/src/components/Settings/OrganizationOverviewMembersSection.tsx`, `frontend-modern/src/components/Settings/OrganizationSharingPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationSharingCreateSection.tsx`, `frontend-modern/src/components/Settings/OrganizationSharingLoadingState.tsx`, `frontend-modern/src/components/Settings/OrganizationOutgoingSharesSection.tsx`, `frontend-modern/src/components/Settings/OrganizationIncomingSharesSection.tsx`, `frontend-modern/src/components/Settings/settingsPanelRegistryContext.tsx`, `frontend-modern/src/components/Settings/useOrganizationAccessPanelState.ts`, `frontend-modern/src/components/Settings/useOrganizationOverviewPanelState.ts`, `frontend-modern/src/components/Settings/useOrganizationSharingPanelState.ts`, `frontend-modern/src/components/Settings/RBACFeatureGateSection.tsx`, `frontend-modern/src/components/Settings/RolesPanel.tsx`, `frontend-modern/src/components/Settings/RolesEditorDialog.tsx`, `frontend-modern/src/components/Settings/useRBACFeatureGateState.ts`, `frontend-modern/src/components/Settings/useRolesPanelState.ts`, `frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx`, `frontend-modern/src/components/Settings/UserAssignmentsDialog.tsx`, and `frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts` + Organization and RBAC tables may own their rows, columns, action cells, and + domain copy, but their table scroll shell and embedded-frame treatment must + come from `frontend-primitives` through `PulseDataGrid`/`Table`; do not add + organization-local `overflow-x-auto`, negative-margin, or side-border + compensation around grids. 3. Route organization and RBAC frontend transport changes through `frontend-modern/src/api/orgs.ts` and `frontend-modern/src/api/rbac.ts` 4. Keep backend organization management and lifecycle handlers aligned through `internal/api/org_handlers.go` and `internal/api/org_lifecycle_handlers.go` 5. Keep RBAC role, assignment, and admin recovery transport aligned through `internal/api/access_control_handlers.go` and `internal/api/enterprise_extension_rbac_admin.go` diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 1d12773bb..c4bb62b10 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -880,6 +880,7 @@ "exact_files": [ "frontend-modern/src/api/__tests__/agentProfiles.test.ts", "frontend-modern/src/components/Settings/__tests__/AgentProfilesPanel.test.tsx", + "frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts", "frontend-modern/src/utils/__tests__/agentProfilesPresentation.test.ts" ] }, @@ -1656,7 +1657,8 @@ "test_prefixes": [], "exact_files": [ "frontend-modern/src/components/Settings/__tests__/APITokenManager.test.tsx", - "frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts" + "frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts", + "frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts" ] }, { @@ -3949,6 +3951,7 @@ "frontend-modern/src/components/Settings/__tests__/OrganizationOverviewPanel.test.tsx", "frontend-modern/src/components/Settings/__tests__/OrganizationSharingPanel.test.tsx", "frontend-modern/src/components/Settings/__tests__/RBACPaywallPanels.test.tsx", + "frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts", "frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts", "frontend-modern/src/utils/__tests__/organizationRolePresentation.test.ts", "frontend-modern/src/utils/__tests__/organizationSettingsPresentation.test.ts", @@ -4624,6 +4627,7 @@ "frontend-modern/src/components/Settings/__tests__/dataHandlingPanelModel.test.ts", "frontend-modern/src/components/Settings/__tests__/SecurityPostureSummary.test.tsx", "frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts", + "frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts", "frontend-modern/src/stores/__tests__/systemSettings.test.ts" ] }, diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 0e0a89f09..d857c7484 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -64,6 +64,10 @@ visibility, and privacy controls to operators. 1. `frontend-modern/src/api/security.ts` shared with `api-contracts`: the security frontend client is both a security/privacy control surface and a canonical API payload contract boundary. 2. `frontend-modern/src/components/Settings/APITokenManager.tsx` shared with `api-contracts`: the API token settings surface is both a security/privacy control surface and a canonical API payload contract boundary. + Token-management table rows are security-facing content, but the visual + table frame and scroll shell belong to `frontend-primitives` + `PulseDataGrid`; do not add token-surface-local overflow, side-border, or + negative-margin wrappers around the inventory grid. 3. `frontend-modern/src/components/Settings/apiTokenManagerModel.ts` shared with `api-contracts`: the pure API token settings model is both a security/privacy control surface and a canonical API payload contract boundary. 4. `frontend-modern/src/components/Settings/DataHandlingPanel.tsx` shared with `frontend-primitives`: the data-handling settings surface is both a security/privacy trust surface and a canonical settings-shell presentation boundary. 5. `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts` shared with `frontend-primitives`: the data-handling settings model is both a security/privacy posture projection and a canonical settings-shell presentation boundary. diff --git a/frontend-modern/src/components/Settings/APITokenManager.tsx b/frontend-modern/src/components/Settings/APITokenManager.tsx index 57b283df7..be30b477b 100644 --- a/frontend-modern/src/components/Settings/APITokenManager.tsx +++ b/frontend-modern/src/components/Settings/APITokenManager.tsx @@ -270,152 +270,149 @@ export const APITokenManager: Component = (props) => { -
- ( - {token.name || 'Untitled'} - ), + ( + {token.name || 'Untitled'} + ), + }, + { + key: 'hint', + label: 'Hint', + render: (token) => ( + {tokenHint(token)} + ), + }, + { + key: 'scopes', + label: 'Scopes', + render: (token) => { + const rawScopes = token.scopes && token.scopes.length > 0 ? token.scopes : ['*']; + const scopeBadges = rawScopes.includes('*') + ? [{ value: '*', label: 'Full' }] + : rawScopes.map((scope) => ({ + value: scope, + label: API_SCOPE_LABELS[scope] ?? scope, + })); + return ( +
+ + {(scope) => { + const isWildcard = scope.value === '*'; + return ( + + {scope.label} + + ); + }} + +
+ ); }, - { - key: 'hint', - label: 'Hint', - render: (token) => ( - {tokenHint(token)} - ), - }, - { - key: 'scopes', - label: 'Scopes', - render: (token) => { - const rawScopes = - token.scopes && token.scopes.length > 0 ? token.scopes : ['*']; - const scopeBadges = rawScopes.includes('*') - ? [{ value: '*', label: 'Full' }] - : rawScopes.map((scope) => ({ - value: scope, - label: API_SCOPE_LABELS[scope] ?? scope, - })); - return ( -
- - {(scope) => { - const isWildcard = scope.value === '*'; - return ( - - {scope.label} - - ); - }} - -
+ }, + { + key: 'usage', + label: 'Usage', + render: (token) => { + const dockerUsageEntry = dockerTokenUsage().get(token.id); + const agentUsageEntry = agentTokenUsage().get(token.id); + const usageSegments: string[] = []; + const usageTitleSegments: string[] = []; + if (dockerUsageEntry) { + usageSegments.push( + dockerUsageEntry.count === 1 + ? (dockerUsageEntry.items[0]?.label ?? 'Container runtime') + : `${dockerUsageEntry.count} container runtimes`, ); - }, - }, - { - key: 'usage', - label: 'Usage', - render: (token) => { - const dockerUsageEntry = dockerTokenUsage().get(token.id); - const agentUsageEntry = agentTokenUsage().get(token.id); - const usageSegments: string[] = []; - const usageTitleSegments: string[] = []; - if (dockerUsageEntry) { - usageSegments.push( - dockerUsageEntry.count === 1 - ? (dockerUsageEntry.items[0]?.label ?? 'Container runtime') - : `${dockerUsageEntry.count} container runtimes`, - ); - usageTitleSegments.push( - `Container runtimes: ${dockerUsageEntry.items.map((runtime) => runtime.label).join(', ')}`, - ); - } - if (agentUsageEntry) { - usageSegments.push( - agentUsageEntry.count === 1 - ? `${agentUsageEntry.items[0]?.label ?? 'Agent'}` - : `${agentUsageEntry.count} agents`, - ); - usageTitleSegments.push( - `Agents: ${agentUsageEntry.items.map((agent) => agent.label).join(', ')}`, - ); - } - const usageSummary = usageSegments.length > 0 ? usageSegments.join(' • ') : '—'; - return ( -
0 ? usageTitleSegments.join('\n') : undefined - } - > - {usageSummary} - 1}> - - - - - Agents sharing this token ({agentUsageEntry!.count}) - - -
+ usageTitleSegments.push( + `Container runtimes: ${dockerUsageEntry.items.map((runtime) => runtime.label).join(', ')}`, ); - }, - }, - { - key: 'createdAt', - label: 'Created', - render: (token) => ( - - {formatRelativeTime(new Date(token.createdAt).getTime())} - - ), - }, - { - key: 'lastUsedAt', - label: 'Last used', - render: (token) => ( - - {token.lastUsedAt - ? formatRelativeTime(new Date(token.lastUsedAt).getTime()) - : 'Never'} - - ), - }, - { - key: 'action', - label: 'Action', - align: 'right', - render: (token) => ( - - ), + {usageSummary} + 1}> + + + + + Agents sharing this token ({agentUsageEntry!.count}) + + +
+ ); }, - ]} - keyExtractor={(token) => token.id} - desktopMinWidth="1000px" - class="border-x-0 sm:border-x border-t-0 rounded-t-none" - /> - + }, + { + key: 'createdAt', + label: 'Created', + render: (token) => ( + + {formatRelativeTime(new Date(token.createdAt).getTime())} + + ), + }, + { + key: 'lastUsedAt', + label: 'Last used', + render: (token) => ( + + {token.lastUsedAt + ? formatRelativeTime(new Date(token.lastUsedAt).getTime()) + : 'Never'} + + ), + }, + { + key: 'action', + label: 'Action', + align: 'right', + render: (token) => ( + + ), + }, + ]} + keyExtractor={(token) => token.id} + desktopMinWidth="1000px" + frame="flush" + /> diff --git a/frontend-modern/src/components/Settings/AgentProfilesPanel.tsx b/frontend-modern/src/components/Settings/AgentProfilesPanel.tsx index c394b50c0..5ad9f7159 100644 --- a/frontend-modern/src/components/Settings/AgentProfilesPanel.tsx +++ b/frontend-modern/src/components/Settings/AgentProfilesPanel.tsx @@ -157,66 +157,64 @@ export const AgentProfilesPanel: Component = () => { 0}> -
- ( - {profile.name} - ), - }, - { - key: 'settings', - label: 'Settings', - render: (profile) => ( - {getSettingsCount(profile)} - ), - }, - { - key: 'agents', - label: 'Agents', - render: (profile) => ( - - - {getAssignmentCount(profile.id)} - - ), - }, - { - key: 'actions', - label: 'Actions', - align: 'right', - render: (profile) => ( -
- - -
- ), - }, - ]} - keyExtractor={(profile) => profile.id} - emptyState={getAgentProfilesEmptyState()} - desktopMinWidth="600px" - class="border-x-0 sm:border-x border-border" - /> -
+ ( + {profile.name} + ), + }, + { + key: 'settings', + label: 'Settings', + render: (profile) => ( + {getSettingsCount(profile)} + ), + }, + { + key: 'agents', + label: 'Agents', + render: (profile) => ( + + + {getAssignmentCount(profile.id)} + + ), + }, + { + key: 'actions', + label: 'Actions', + align: 'right', + render: (profile) => ( +
+ + +
+ ), + }, + ]} + keyExtractor={(profile) => profile.id} + emptyState={getAgentProfilesEmptyState()} + desktopMinWidth="600px" + frame="flush" + />
@@ -235,93 +233,88 @@ export const AgentProfilesPanel: Component = () => { 0}> -
- ( -
- - {agent.displayName || agent.hostname} - - - ({agent.hostname}) - -
- ), - }, - { - key: 'profile', - label: 'Profile', - render: (agent) => { - const assignment = () => getAgentAssignment(agent.assignmentId); - return ( - - handleAssign(agent.assignmentId, e.currentTarget.value) - } - selectBaseClass="min-h-10 sm:min-h-9 w-full sm:max-w-xs rounded-md border border-border bg-surface px-2.5 py-1.5 text-sm text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" - > - - - - - - {(profile) => ( - - )} - - - ); - }, - }, - { - key: 'status', - label: 'Status', - render: (agent) => { - const indicator = () => getAgentStatusIndicator({ status: agent.status }); - return ( - - {indicator().label} - - ); - }, - }, - { - key: 'lastSeen', - label: 'Last Seen', - hiddenOnMobile: true, - render: (agent) => ( - - {agent.lastSeen ? formatRelativeTime(agent.lastSeen) : 'Never'} + ( +
+ + {agent.displayName || agent.hostname} - ), + + ({agent.hostname}) + +
+ ), + }, + { + key: 'profile', + label: 'Profile', + render: (agent) => { + const assignment = () => getAgentAssignment(agent.assignmentId); + return ( + handleAssign(agent.assignmentId, e.currentTarget.value)} + selectBaseClass="min-h-10 sm:min-h-9 w-full sm:max-w-xs rounded-md border border-border bg-surface px-2.5 py-1.5 text-sm text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + > + + + + + + {(profile) => ( + + )} + + + ); }, - ]} - keyExtractor={(agent) => agent.id} - emptyState={getAgentProfileAssignmentsEmptyState()} - desktopMinWidth="800px" - class="border-x-0 sm:border-x" - /> -
+ }, + { + key: 'status', + label: 'Status', + render: (agent) => { + const indicator = () => getAgentStatusIndicator({ status: agent.status }); + return ( + + {indicator().label} + + ); + }, + }, + { + key: 'lastSeen', + label: 'Last Seen', + hiddenOnMobile: true, + render: (agent) => ( + + {agent.lastSeen ? formatRelativeTime(agent.lastSeen) : 'Never'} + + ), + }, + ]} + keyExtractor={(agent) => agent.id} + emptyState={getAgentProfileAssignmentsEmptyState()} + desktopMinWidth="800px" + frame="flush" + />
diff --git a/frontend-modern/src/components/Settings/OrganizationAccessMembersSection.tsx b/frontend-modern/src/components/Settings/OrganizationAccessMembersSection.tsx index c1fba8fbc..b5d5db462 100644 --- a/frontend-modern/src/components/Settings/OrganizationAccessMembersSection.tsx +++ b/frontend-modern/src/components/Settings/OrganizationAccessMembersSection.tsx @@ -18,97 +18,96 @@ export const OrganizationAccessMembersSection: Component ( {(currentOrg) => ( -
- {member.userId}, - }, - { - key: 'role', - label: 'Role', - render: (member) => { - const role = normalizeRole(member.role); - const isOwner = () => member.userId === currentOrg().ownerUserId; - return ( - - {role} - + {member.userId}, + }, + { + key: 'role', + label: 'Role', + render: (member) => { + const role = normalizeRole(member.role); + const isOwner = () => member.userId === currentOrg().ownerUserId; + return ( + + {role} + + } + > + { + void props.state.updateRole( + member, + event.currentTarget.value as OrganizationRole, + ); + }} + disabled={ + props.state.saving() || + (isOwner() && props.currentUser !== currentOrg().ownerUserId) } + selectBaseClass="rounded-md border border-border bg-surface px-2 py-1 text-xs text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-60" > - { - void props.state.updateRole( - member, - event.currentTarget.value as OrganizationRole, - ); - }} - disabled={ - props.state.saving() || - (isOwner() && props.currentUser !== currentOrg().ownerUserId) - } - selectBaseClass="rounded-md border border-border bg-surface px-2 py-1 text-xs text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-60" + + option.value !== 'owner' || + props.currentUser === currentOrg().ownerUserId, + )} > - - option.value !== 'owner' || - props.currentUser === currentOrg().ownerUserId, - )} - > - {(option) => } - - - - ); - }, + {(option) => } + + + + ); }, - { - key: 'addedAt', - label: 'Added', - render: (member) => {formatOrgDate(member.addedAt)}, + }, + { + key: 'addedAt', + label: 'Added', + render: (member) => {formatOrgDate(member.addedAt)}, + }, + { + key: 'actions', + label: 'Actions', + align: 'right', + render: (member: OrganizationMember) => { + const isOwner = () => member.userId === currentOrg().ownerUserId; + return ( + + + + ); }, - { - key: 'actions', - label: 'Actions', - align: 'right', - render: (member: OrganizationMember) => { - const isOwner = () => member.userId === currentOrg().ownerUserId; - return ( - - - - ); - }, - }, - ]} - keyExtractor={(member) => member.userId} - emptyState={getOrganizationAccessEmptyState()} - desktopMinWidth="700px" - /> -
+ }, + ]} + keyExtractor={(member) => member.userId} + emptyState={getOrganizationAccessEmptyState()} + desktopMinWidth="700px" + /> )}
); diff --git a/frontend-modern/src/components/Settings/OrganizationIncomingSharesSection.tsx b/frontend-modern/src/components/Settings/OrganizationIncomingSharesSection.tsx index 6f10f3a59..03f62b76c 100644 --- a/frontend-modern/src/components/Settings/OrganizationIncomingSharesSection.tsx +++ b/frontend-modern/src/components/Settings/OrganizationIncomingSharesSection.tsx @@ -24,108 +24,106 @@ export const OrganizationIncomingSharesSection: Component< return (

Incoming Shares

-
- ( - {share.sourceOrgName || share.sourceOrgId} - ), + ( + {share.sourceOrgName || share.sourceOrgId} + ), + }, + { + key: 'resource', + label: 'Resource', + render: (share) => ( +
+ {share.resourceName || share.resourceId} + + {share.resourceType}:{share.resourceId} + +
+ ), + }, + { + key: 'accessRole', + label: 'Access', + render: (share) => { + const role = normalizeOrganizationShareRole(share.accessRole); + return ( + + {role} + + ); }, - { - key: 'resource', - label: 'Resource', - render: (share) => ( -
- {share.resourceName || share.resourceId} - - {share.resourceType}:{share.resourceId} - -
- ), - }, - { - key: 'accessRole', - label: 'Access', - render: (share) => { - const role = normalizeOrganizationShareRole(share.accessRole); - return ( - - {role} - - ); - }, - }, - { - key: 'status', - label: 'Status', - render: (share) => ( -
- - {getOrganizationShareStatusLabel(share.status)} - - - {getOrganizationShareStatusDescription( - share.status, - share.acceptedAt, - share.acceptedBy, - )} - -
- ), - }, - { - key: 'createdAt', - label: 'Requested', - render: (share) => {formatOrgDate(share.createdAt)}, - }, - { - key: 'actions', - label: 'Actions', - align: 'right', - render: (share) => ( - -
- - - + }, + { + key: 'status', + label: 'Status', + render: (share) => ( +
+ + {getOrganizationShareStatusLabel(share.status)} + + + {getOrganizationShareStatusDescription( + share.status, + share.acceptedAt, + share.acceptedBy, + )} + +
+ ), + }, + { + key: 'createdAt', + label: 'Requested', + render: (share) => {formatOrgDate(share.createdAt)}, + }, + { + key: 'actions', + label: 'Actions', + align: 'right', + render: (share) => ( + +
+ -
-
- ), - }, - ]} - keyExtractor={(share) => share.id} - emptyState={getOrganizationIncomingSharesEmptyState()} - desktopMinWidth="980px" - class="border-x-0 sm:border-x sm:border-t sm:border-b sm:rounded-md border-y border-border" - /> -
+
+ +
+ + ), + }, + ]} + keyExtractor={(share) => share.id} + emptyState={getOrganizationIncomingSharesEmptyState()} + desktopMinWidth="980px" + />
); }; diff --git a/frontend-modern/src/components/Settings/OrganizationOutgoingSharesSection.tsx b/frontend-modern/src/components/Settings/OrganizationOutgoingSharesSection.tsx index ceb548d8d..106acd2bd 100644 --- a/frontend-modern/src/components/Settings/OrganizationOutgoingSharesSection.tsx +++ b/frontend-modern/src/components/Settings/OrganizationOutgoingSharesSection.tsx @@ -25,97 +25,95 @@ export const OrganizationOutgoingSharesSection: Component< return (

Outgoing Shares

-
- ( -
- {share.resourceName || share.resourceId} - - {share.resourceType}:{share.resourceId} - -
- ), - }, - { - key: 'targetOrgId', - label: 'Target Org', - render: (share) => ( - - {props.state.orgNameById().get(share.targetOrgId) || share.targetOrgId} + ( +
+ {share.resourceName || share.resourceId} + + {share.resourceType}:{share.resourceId} - ), +
+ ), + }, + { + key: 'targetOrgId', + label: 'Target Org', + render: (share) => ( + + {props.state.orgNameById().get(share.targetOrgId) || share.targetOrgId} + + ), + }, + { + key: 'accessRole', + label: 'Access', + render: (share) => { + const role = normalizeOrganizationShareRole(share.accessRole); + return ( + + {role} + + ); }, - { - key: 'accessRole', - label: 'Access', - render: (share) => { - const role = normalizeOrganizationShareRole(share.accessRole); - return ( - - {role} - - ); - }, - }, - { - key: 'status', - label: 'Status', - render: (share) => ( -
- - {getOrganizationShareStatusLabel(share.status)} - - - {getOrganizationShareStatusDescription( - share.status, - share.acceptedAt, - share.acceptedBy, - )} - -
- ), - }, - { - key: 'createdAt', - label: 'Requested', - render: (share) => {formatOrgDate(share.createdAt)}, - }, - { - key: 'actions', - label: 'Actions', - align: 'right', - render: (share) => ( - - - - ), - }, - ]} - keyExtractor={(share) => share.id} - emptyState={getOrganizationOutgoingSharesEmptyState()} - desktopMinWidth="980px" - class="border-x-0 sm:border-x sm:border-t sm:border-b sm:rounded-md border-y border-border" - /> -
+ }, + { + key: 'status', + label: 'Status', + render: (share) => ( +
+ + {getOrganizationShareStatusLabel(share.status)} + + + {getOrganizationShareStatusDescription( + share.status, + share.acceptedAt, + share.acceptedBy, + )} + +
+ ), + }, + { + key: 'createdAt', + label: 'Requested', + render: (share) => {formatOrgDate(share.createdAt)}, + }, + { + key: 'actions', + label: 'Actions', + align: 'right', + render: (share) => ( + + + + ), + }, + ]} + keyExtractor={(share) => share.id} + emptyState={getOrganizationOutgoingSharesEmptyState()} + desktopMinWidth="980px" + />
); }; diff --git a/frontend-modern/src/components/Settings/OrganizationOverviewMembersSection.tsx b/frontend-modern/src/components/Settings/OrganizationOverviewMembersSection.tsx index d71b7129a..10f8fa230 100644 --- a/frontend-modern/src/components/Settings/OrganizationOverviewMembersSection.tsx +++ b/frontend-modern/src/components/Settings/OrganizationOverviewMembersSection.tsx @@ -13,40 +13,38 @@ export const OrganizationOverviewMembersSection: Component< > = (props) => (

Membership

-
- {member.userId}, + {member.userId}, + }, + { + key: 'role', + label: 'Role', + render: (member) => { + const role = normalizeRole(member.role); + return ( + + {role} + + ); }, - { - key: 'role', - label: 'Role', - render: (member) => { - const role = normalizeRole(member.role); - return ( - - {role} - - ); - }, - }, - { - key: 'addedAt', - label: 'Added', - render: (member) => {formatOrgDate(member.addedAt)}, - }, - ]} - keyExtractor={(member) => member.userId} - emptyState={getOrganizationOverviewMembersEmptyState()} - desktopMinWidth="560px" - class="border-x-0 sm:border-x sm:border-t sm:border-b sm:rounded-md border-y border-border" - /> -
+ }, + { + key: 'addedAt', + label: 'Added', + render: (member) => {formatOrgDate(member.addedAt)}, + }, + ]} + keyExtractor={(member) => member.userId} + emptyState={getOrganizationOverviewMembersEmptyState()} + desktopMinWidth="560px" + />
); diff --git a/frontend-modern/src/components/Settings/RolesPanel.tsx b/frontend-modern/src/components/Settings/RolesPanel.tsx index c60f39d6b..fa687b200 100644 --- a/frontend-modern/src/components/Settings/RolesPanel.tsx +++ b/frontend-modern/src/components/Settings/RolesPanel.tsx @@ -47,77 +47,75 @@ export const RolesPanel: Component = () => { -
- ( -
- - {role.name} - - - - - {role.description} -
- ), - }, - { - key: 'permissions', - label: 'Permissions', - render: (role) => ( -
- - {(perm) => ( - - {perm.action}:{perm.resource} - - )} - -
- ), - }, - { - key: 'actions', - label: 'Actions', - align: 'right', - render: (role) => ( -
- - - - + ( +
+ + {role.name} - Read-only + -
- ), - }, - ]} - keyExtractor={(role) => role.id} - emptyState={getRolesEmptyState()} - desktopMinWidth="620px" - class="border-x-0 sm:border-x" - /> -
+ + {role.description} +
+ ), + }, + { + key: 'permissions', + label: 'Permissions', + render: (role) => ( +
+ + {(perm) => ( + + {perm.action}:{perm.resource} + + )} + +
+ ), + }, + { + key: 'actions', + label: 'Actions', + align: 'right', + render: (role) => ( +
+ + + + + + Read-only + +
+ ), + }, + ]} + keyExtractor={(role) => role.id} + emptyState={getRolesEmptyState()} + desktopMinWidth="620px" + frame="flush" + />
diff --git a/frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx b/frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx index 061bd7ce6..ffc0653bf 100644 --- a/frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx +++ b/frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx @@ -75,58 +75,56 @@ export const UserAssignmentsPanel: Component = () => { state.filteredAssignments().length > 0 } > -
- ( - {assignment.username} - ), - }, - { - key: 'assignedRoles', - label: 'Assigned Roles', - render: (assignment) => ( -
- - No roles assigned - - - {(roleId) => ( - - - {state.getRoleName(roleId)} - - )} - -
- ), - }, - { - key: 'actions', - label: 'Actions', - align: 'right', - render: (assignment) => ( - - ), - }, - ]} - keyExtractor={(assignment) => assignment.username} - emptyState={emptyStateCopy().title} - desktopMinWidth="620px" - class="border-x-0 sm:border-x" - /> -
+ ( + {assignment.username} + ), + }, + { + key: 'assignedRoles', + label: 'Assigned Roles', + render: (assignment) => ( +
+ + No roles assigned + + + {(roleId) => ( + + + {state.getRoleName(roleId)} + + )} + +
+ ), + }, + { + key: 'actions', + label: 'Actions', + align: 'right', + render: (assignment) => ( + + ), + }, + ]} + keyExtractor={(assignment) => assignment.username} + emptyState={emptyStateCopy().title} + desktopMinWidth="620px" + frame="flush" + /> diff --git a/frontend-modern/src/components/shared/PulseDataGrid.tsx b/frontend-modern/src/components/shared/PulseDataGrid.tsx index edc8203ec..5f3e00b2e 100644 --- a/frontend-modern/src/components/shared/PulseDataGrid.tsx +++ b/frontend-modern/src/components/shared/PulseDataGrid.tsx @@ -9,6 +9,7 @@ import { } from '@/components/shared/Table'; import { getPulseDataGridAlignClass, + getPulseDataGridFrameClass, getPulseDataGridWidthAttr, isPulseDataGridInteractiveTarget, type PulseDataGridProps, @@ -36,12 +37,13 @@ export function PulseDataGrid(props: PulseDataGridProps) { 'desktopMinWidth', 'mobileMinWidth', 'class', + 'frame', ]); const grid = usePulseDataGridState(local); return ( -
+
{ expect(configuredNodeTablesSource).toContain('wrapperClass="max-h-[600px] overflow-y-auto"'); expect(storageContentCardSource).toContain('', ); + for (const source of [ + agentProfilesPanelSource, + apiTokenManagerSource, + organizationAccessMembersSectionSource, + organizationIncomingSharesSectionSource, + organizationOutgoingSharesSectionSource, + organizationOverviewMembersSectionSource, + rolesPanelSource, + userAssignmentsPanelSource, + ]) { + expect(source).toContain(' { it('keeps the shared pulse data grid on shell, runtime, and model owners', () => { expect(pulseDataGridSource).toContain('usePulseDataGridState'); expect(pulseDataGridSource).toContain('getPulseDataGridAlignClass'); + expect(pulseDataGridSource).toContain('getPulseDataGridFrameClass'); expect(pulseDataGridSource).toContain('getPulseDataGridWidthAttr'); expect(pulseDataGridSource).toContain('isPulseDataGridInteractiveTarget'); expect(pulseDataGridSource).toContain("from '@/components/shared/Table'"); @@ -41,6 +42,13 @@ describe('PulseDataGrid', () => { expect(pulseDataGridStateSource).toContain('reconcile('); expect(pulseDataGridModelSource).toContain('export const getPulseDataGridAlignClass'); + expect(pulseDataGridModelSource).toContain( + "export type PulseDataGridFrame = 'default' | 'flush'", + ); + expect(pulseDataGridModelSource).toContain('export const getPulseDataGridFrameClass'); + expect(pulseDataGridModelSource).toContain( + "flush: 'overflow-hidden rounded-none border-0 bg-surface'", + ); expect(pulseDataGridModelSource).toContain('export const getPulseDataGridWidthAttr'); expect(pulseDataGridModelSource).toContain('export const isPulseDataGridInteractiveTarget'); expect(pulseDataGridModelSource).toContain('target.closest('); @@ -114,6 +122,25 @@ describe('PulseDataGrid', () => { expect(onRowClick).not.toHaveBeenCalled(); }); + it('applies the shared flush frame for grids embedded inside existing panels', () => { + render(() => ( + + data={[{ id: '1', name: 'Tower' }]} + columns={[{ key: 'name', label: 'Name' }]} + keyExtractor={(row) => row.id} + frame="flush" + /> + )); + + const table = screen.getByRole('table'); + const frame = table.parentElement?.parentElement; + + expect(frame).not.toBeNull(); + expect(frame).toHaveClass('rounded-none'); + expect(frame).toHaveClass('border-0'); + expect(frame).not.toHaveClass('rounded-md'); + }); + it('keeps the same row DOM node when data refreshes with the same key', () => { const [rows, setRows] = createSignal([{ id: '1', name: 'Tower' }]); diff --git a/frontend-modern/src/components/shared/pulseDataGridModel.ts b/frontend-modern/src/components/shared/pulseDataGridModel.ts index e288021d5..e3d2b1efd 100644 --- a/frontend-modern/src/components/shared/pulseDataGridModel.ts +++ b/frontend-modern/src/components/shared/pulseDataGridModel.ts @@ -52,13 +52,26 @@ export interface PulseDataGridProps { /** Custom classes applied to the root container */ class?: string; + + /** Root frame treatment. Use flush when embedding directly inside an existing panel/card frame. */ + frame?: PulseDataGridFrame; } +export type PulseDataGridFrame = 'default' | 'flush'; + +const PULSE_DATA_GRID_FRAME_CLASS: Record = { + default: 'overflow-hidden rounded-md border border-border bg-surface', + flush: 'overflow-hidden rounded-none border-0 bg-surface', +}; + export type PulseDataGridStableRow = { __pulseKey: string | number; value: T; }; +export const getPulseDataGridFrameClass = (frame?: PulseDataGridFrame) => + PULSE_DATA_GRID_FRAME_CLASS[frame ?? 'default']; + export const getPulseDataGridAlignClass = (align?: 'left' | 'center' | 'right') => { switch (align) { case 'center':