diff --git a/frontend-modern/src/components/Settings/ConnectionsTable.tsx b/frontend-modern/src/components/Settings/ConnectionsTable.tsx index d39e1aa57..99065d92c 100644 --- a/frontend-modern/src/components/Settings/ConnectionsTable.tsx +++ b/frontend-modern/src/components/Settings/ConnectionsTable.tsx @@ -1,4 +1,12 @@ -import { Component, For, Show, type Accessor } from 'solid-js'; +import { + Component, + createEffect, + createMemo, + createSignal, + For, + Show, + type Accessor, +} from 'solid-js'; import { Card } from '@/components/shared/Card'; import { Table, @@ -9,7 +17,13 @@ import { TableRow, } from '@/components/shared/Table'; import type { Connection } from '@/api/connections'; -import { fleetSignalClassName, type InfrastructureSystemRow } from './connectionsTableModel'; +import { + CONNECTIONS_TABLE_INITIAL_VISIBLE_ROWS, + connectionsTableVisibilityState, + fleetSignalClassName, + type InfrastructureSystemRow, + visibleConnectionsTableRows, +} from './connectionsTableModel'; import type { ConnectionRowActions } from './useConnectionRowActions'; import { getInfrastructureEmptyStateSummary } from '@/utils/infrastructureOnboardingPresentation'; @@ -46,9 +60,32 @@ const removeConfirmClass = 'inline-flex items-center rounded-md bg-rose-600 px-2.5 py-1 text-xs font-medium text-white transition-colors hover:bg-rose-700 disabled:cursor-not-allowed disabled:opacity-60'; export const ConnectionsTable: Component = (props) => { + const [visibleLimit, setVisibleLimit] = createSignal(CONNECTIONS_TABLE_INITIAL_VISIBLE_ROWS); const hasActions = () => Boolean(props.actions) || Boolean(props.onEdit); const colSpan = () => (hasActions() ? 6 : 5); + const rows = createMemo(() => props.rows()); + const visibility = createMemo(() => + connectionsTableVisibilityState(rows().length, visibleLimit()), + ); + const visibleRows = createMemo(() => + visibleConnectionsTableRows(rows(), visibility().visibleLimit), + ); + const shouldShowVisibilityStatus = () => + visibility().totalRows > CONNECTIONS_TABLE_INITIAL_VISIBLE_ROWS; + const showMoreRows = () => { + setVisibleLimit(visibility().nextVisibleLimit); + }; + + createEffect(() => { + const totalRows = rows().length; + setVisibleLimit((current) => { + if (totalRows <= CONNECTIONS_TABLE_INITIAL_VISIBLE_ROWS) { + return CONNECTIONS_TABLE_INITIAL_VISIBLE_ROWS; + } + return Math.min(Math.max(current, CONNECTIONS_TABLE_INITIAL_VISIBLE_ROWS), totalRows); + }); + }); return ( @@ -79,7 +116,7 @@ export const ConnectionsTable: Component = (props) => { 0} + when={rows().length > 0} fallback={
@@ -106,7 +143,21 @@ export const ConnectionsTable: Component = (props) => {
} > - + +
+ {visibility().statusText} +
+
+
@@ -130,7 +181,7 @@ export const ConnectionsTable: Component = (props) => { - + {(row) => { const pauseLabel = () => (row.enabled ? 'Pause' : 'Resume'); const isPauseBusy = () => props.actions?.pendingAction(row.id) === 'pause'; @@ -331,6 +382,19 @@ export const ConnectionsTable: Component = (props) => {
+ +
+ {visibility().statusText} + +
+
); diff --git a/frontend-modern/src/components/Settings/__tests__/ConnectionsTable.test.tsx b/frontend-modern/src/components/Settings/__tests__/ConnectionsTable.test.tsx index 7ed7363ae..243952044 100644 --- a/frontend-modern/src/components/Settings/__tests__/ConnectionsTable.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ConnectionsTable.test.tsx @@ -1,8 +1,11 @@ import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ConnectionsTable } from '../ConnectionsTable'; -import type { InfrastructureSystemRow } from '../connectionsTableModel'; -import { connectionAgentIdentitySummary } from '../connectionsTableModel'; +import { + CONNECTIONS_TABLE_INITIAL_VISIBLE_ROWS, + connectionAgentIdentitySummary, + type InfrastructureSystemRow, +} from '../connectionsTableModel'; import type { Connection, ConnectionAgentIdentity } from '@/api/connections'; import type { ConnectionRowActions } from '../useConnectionRowActions'; @@ -165,6 +168,18 @@ describe('ConnectionsTable', () => { detail: 'Commands are disabled by policy.', tone: 'info', }, + { + key: 'config-drift', + label: 'Config pending', + detail: 'Waiting for applied configuration confirmation.', + tone: 'warning', + }, + { + key: 'config-drift', + label: 'Config unknown', + detail: 'No desired/applied config fingerprint has been reported yet.', + tone: 'warning', + }, ], }), ]} @@ -180,6 +195,49 @@ describe('ConnectionsTable', () => { 'title', 'Commands are disabled by policy.', ); + expect(screen.getByText('Config pending')).toHaveAttribute( + 'title', + 'Waiting for applied configuration confirmation.', + ); + expect(screen.getByText('Config unknown')).toHaveAttribute( + 'title', + 'No desired/applied config fingerprint has been reported yet.', + ); + }); + + it('keeps large row sets bounded behind an explicit show-more path', () => { + const rows = Array.from({ length: CONNECTIONS_TABLE_INITIAL_VISIBLE_ROWS + 5 }, (_, index) => + row({ + id: `system-${index}`, + name: `system-${index}`, + host: undefined, + connection: connectionFixture({ + id: `system-${index}`, + name: `system-${index}`, + }), + }), + ); + + render(() => rows} />); + + expect(screen.getByText('system-0')).toBeInTheDocument(); + expect( + screen.getByText(`system-${CONNECTIONS_TABLE_INITIAL_VISIBLE_ROWS - 1}`), + ).toBeInTheDocument(); + expect(screen.queryByText(`system-${CONNECTIONS_TABLE_INITIAL_VISIBLE_ROWS}`)).toBeNull(); + expect( + screen.getAllByText( + `Showing ${CONNECTIONS_TABLE_INITIAL_VISIBLE_ROWS} of ${rows.length} monitored systems. 5 more systems available.`, + ), + ).toHaveLength(2); + + fireEvent.click(screen.getByRole('button', { name: 'Show remaining 5 monitored systems.' })); + + expect( + screen.getByText(`system-${CONNECTIONS_TABLE_INITIAL_VISIBLE_ROWS}`), + ).toBeInTheDocument(); + expect(screen.getByText(`Showing all ${rows.length} monitored systems.`)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Show remaining/i })).toBeNull(); }); it('renders Edit / Pause / Remove buttons when actions and onEdit are provided', () => { diff --git a/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts b/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts index 24fcb0c59..13825ae8d 100644 --- a/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts @@ -71,6 +71,61 @@ describe('useConnectionsLedger', () => { ]); }); + it('reuses large stable row models across equivalent ledger refreshes', async () => { + const connections: Connection[] = Array.from({ length: 120 }, (_, index) => ({ + id: `agent:stable-${index}`, + type: 'agent', + name: `stable-${index}`, + address: `stable-${index}`, + state: 'active', + stateReason: '', + enabled: true, + surfaces: ['host'], + scope: { host: true }, + lastSeen: null, + lastError: null, + source: 'agent', + fleet: { + enrollmentState: 'enrolled', + livenessState: 'active', + versionDrift: 'current', + adapterHealth: 'healthy', + configRollout: 'reported', + credentialStatus: 'verified', + updateStatus: 'current', + remoteControl: 'disabled', + configDrift: { status: 'current' }, + rollout: { status: 'current' }, + credentialHealth: { status: 'verified', kind: 'agent-token' }, + commandPolicy: { status: 'disabled' }, + }, + capabilities: { supportsPause: false, supportsScope: false, supportsTest: false }, + })); + const listSpy = vi.spyOn(ConnectionsAPI, 'list').mockImplementation(async () => ({ + connections: connections.map((connection) => ({ + ...connection, + scope: { ...(connection.scope ?? {}) }, + fleet: connection.fleet ? { ...connection.fleet } : undefined, + capabilities: { ...connection.capabilities }, + })), + systems: [], + })); + + const { result } = renderHook(() => useConnectionsLedger()); + + await waitFor(() => expect(result.rows()).toHaveLength(120)); + const firstRows = result.rows(); + + result.reload(); + + await waitFor(() => expect(listSpy).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(result.loading()).toBe(false)); + expect(result.rows()).toHaveLength(120); + expect(result.rows()[0]).toBe(firstRows[0]); + expect(result.rows()[75]).toBe(firstRows[75]); + expect(result.rows()[119]).toBe(firstRows[119]); + }); + it('prioritizes explicit rollout drift, credential, and command-policy posture', async () => { const connections: Connection[] = [ { @@ -203,12 +258,70 @@ describe('useConnectionsLedger', () => { }, capabilities: { supportsPause: false, supportsScope: false, supportsTest: false }, }, + { + id: 'agent:config-pending', + type: 'agent', + name: 'config-pending', + address: 'config-pending', + state: 'active', + stateReason: '', + enabled: true, + surfaces: ['host'], + scope: { host: true }, + lastSeen: '2026-04-23T12:00:00Z', + lastError: null, + source: 'agent', + fleet: { + enrollmentState: 'enrolled', + livenessState: 'active', + versionDrift: 'current', + adapterHealth: 'healthy', + configRollout: 'reported', + credentialStatus: 'verified', + updateStatus: 'current', + remoteControl: 'enabled', + configDrift: { status: 'pending' }, + rollout: { status: 'current' }, + credentialHealth: { status: 'verified', kind: 'agent-token' }, + commandPolicy: { status: 'enabled' }, + }, + capabilities: { supportsPause: false, supportsScope: false, supportsTest: false }, + }, + { + id: 'agent:config-unknown', + type: 'agent', + name: 'config-unknown', + address: 'config-unknown', + state: 'active', + stateReason: '', + enabled: true, + surfaces: ['host'], + scope: { host: true }, + lastSeen: '2026-04-23T12:00:00Z', + lastError: null, + source: 'agent', + fleet: { + enrollmentState: 'enrolled', + livenessState: 'active', + versionDrift: 'current', + adapterHealth: 'healthy', + configRollout: 'reported', + credentialStatus: 'verified', + updateStatus: 'current', + remoteControl: 'enabled', + configDrift: { status: 'unknown' }, + rollout: { status: 'current' }, + credentialHealth: { status: 'verified', kind: 'agent-token' }, + commandPolicy: { status: 'enabled' }, + }, + capabilities: { supportsPause: false, supportsScope: false, supportsTest: false }, + }, ]; vi.spyOn(ConnectionsAPI, 'list').mockResolvedValue({ connections, systems: [] }); const { result } = renderHook(() => useConnectionsLedger()); - await waitFor(() => expect(result.rows()).toHaveLength(4)); + await waitFor(() => expect(result.rows()).toHaveLength(6)); const byID = new Map(result.rows().map((row) => [row.id, row])); expect(byID.get('agent:drifted')?.fleetHighlights.map((signal) => signal.label)).toEqual([ @@ -227,6 +340,12 @@ describe('useConnectionsLedger', () => { expect( byID.get('agent:remote-disabled')?.fleetHighlights.map((signal) => signal.label), ).toEqual(['Remote control disabled']); + expect(byID.get('agent:config-pending')?.fleetHighlights.map((signal) => signal.label)).toEqual( + ['Config pending', 'Remote control enabled'], + ); + expect(byID.get('agent:config-unknown')?.fleetHighlights.map((signal) => signal.label)).toEqual( + ['Config unknown', 'Remote control enabled'], + ); }); it('renders a Proxmox cluster row from the canonical system metadata', async () => { diff --git a/frontend-modern/src/components/Settings/connectionsTableModel.ts b/frontend-modern/src/components/Settings/connectionsTableModel.ts index c57717d4e..200944185 100644 --- a/frontend-modern/src/components/Settings/connectionsTableModel.ts +++ b/frontend-modern/src/components/Settings/connectionsTableModel.ts @@ -257,6 +257,71 @@ export interface FleetGovernanceSignal { tone: FleetGovernanceSignalTone; } +export const CONNECTIONS_TABLE_INITIAL_VISIBLE_ROWS = 80; +export const CONNECTIONS_TABLE_VISIBLE_ROWS_INCREMENT = 80; + +export interface ConnectionsTableVisibilityState { + totalRows: number; + visibleLimit: number; + visibleRows: number; + hiddenRows: number; + isBounded: boolean; + statusText: string; + showMoreLabel: string; + showMoreAriaLabel: string; + nextVisibleLimit: number; +} + +export const connectionsTableVisibilityState = ( + totalRows: number, + visibleLimit: number, +): ConnectionsTableVisibilityState => { + const normalizedTotal = Math.max(0, Math.floor(totalRows)); + const normalizedLimit = Math.max( + CONNECTIONS_TABLE_INITIAL_VISIBLE_ROWS, + Math.floor(visibleLimit), + ); + const visibleRows = Math.min(normalizedTotal, normalizedLimit); + const hiddenRows = Math.max(0, normalizedTotal - visibleRows); + const nextBatch = Math.min(CONNECTIONS_TABLE_VISIBLE_ROWS_INCREMENT, hiddenRows); + const nextVisibleLimit = Math.min( + normalizedTotal, + visibleRows + CONNECTIONS_TABLE_VISIBLE_ROWS_INCREMENT, + ); + const systemLabel = normalizedTotal === 1 ? 'system' : 'systems'; + const remainingLabel = hiddenRows === 1 ? 'system' : 'systems'; + const statusText = + hiddenRows > 0 + ? `Showing ${visibleRows} of ${normalizedTotal} monitored ${systemLabel}. ${hiddenRows} more ${remainingLabel} available.` + : `Showing all ${normalizedTotal} monitored ${systemLabel}.`; + + return { + totalRows: normalizedTotal, + visibleLimit: normalizedLimit, + visibleRows, + hiddenRows, + isBounded: hiddenRows > 0, + statusText, + showMoreLabel: + hiddenRows > CONNECTIONS_TABLE_VISIBLE_ROWS_INCREMENT + ? `Show next ${CONNECTIONS_TABLE_VISIBLE_ROWS_INCREMENT}` + : `Show remaining ${hiddenRows}`, + showMoreAriaLabel: + hiddenRows > CONNECTIONS_TABLE_VISIBLE_ROWS_INCREMENT + ? `Show next ${nextBatch} monitored systems. ${hiddenRows} currently hidden.` + : `Show remaining ${hiddenRows} monitored ${remainingLabel}.`, + nextVisibleLimit, + }; +}; + +export const visibleConnectionsTableRows = ( + rows: readonly TRow[], + visibleLimit: number, +): readonly TRow[] => { + if (rows.length <= visibleLimit) return rows; + return rows.slice(0, visibleLimit); +}; + const DEFAULT_FLEET_GOVERNANCE: ConnectionFleetGovernance = { enrollmentState: 'pending', livenessState: 'pending', @@ -600,9 +665,7 @@ const rolloutSignal = (state: ConnectionFleetRolloutState): FleetGovernanceSigna } }; -const credentialHealthSignal = ( - state: ConnectionFleetCredentialHealth, -): FleetGovernanceSignal => { +const credentialHealthSignal = (state: ConnectionFleetCredentialHealth): FleetGovernanceSignal => { switch (state.status) { case 'verified': return { diff --git a/frontend-modern/src/components/Settings/useConnectionsLedger.ts b/frontend-modern/src/components/Settings/useConnectionsLedger.ts index 81aed4082..089e83e8b 100644 --- a/frontend-modern/src/components/Settings/useConnectionsLedger.ts +++ b/frontend-modern/src/components/Settings/useConnectionsLedger.ts @@ -175,6 +175,78 @@ interface ClusterRollup { lastSeen?: string; } +interface CachedInfrastructureSystemRow { + signature: string; + row: InfrastructureSystemRow; +} + +const stableRecordEntries = (record?: Record | null): [string, boolean][] => + Object.entries(record ?? {}).sort(([left], [right]) => left.localeCompare(right)); + +const connectionRowSignature = (connection: Connection): string => + JSON.stringify({ + id: connection.id, + type: connection.type, + name: connection.name, + address: connection.address, + state: connection.state, + stateReason: connection.stateReason, + enabled: connection.enabled, + surfaces: connection.surfaces, + scope: stableRecordEntries(connection.scope), + lastSeen: connection.lastSeen, + lastActivityText: connectionLastActivityText(connection), + lastError: connection.lastError, + source: connection.source, + capabilities: connection.capabilities, + agentVersion: connection.agentVersion, + expectedAgentVersion: connection.expectedAgentVersion, + agentUpdateAvailable: connection.agentUpdateAvailable, + agentIdentity: connection.agentIdentity, + hostAliases: connection.hostAliases, + fleet: connection.fleet, + }); + +const connectionRowSignatureForID = ( + connectionsByID: Map, + id?: string | null, +): string | null => { + if (!id) return null; + const connection = connectionsByID.get(id); + return connection ? connectionRowSignature(connection) : null; +}; + +const systemRowSignature = ( + system: ConnectionSystem, + connectionsByID: Map, +): string => + JSON.stringify({ + id: system.id, + type: system.type, + clusterName: system.clusterName, + components: system.components, + members: (system.members ?? []).map((member) => ({ + id: member.id, + name: member.name, + endpoint: member.endpoint, + hostAliases: member.hostAliases, + state: member.state, + lastSeen: member.lastSeen, + lastActivityText: lastActivityTextFromLastSeen(member.lastSeen), + primary: member.primary, + agentConnectionId: member.agentConnectionId, + agentConnectionSignature: connectionRowSignatureForID( + connectionsByID, + member.agentConnectionId, + ), + })), + componentConnections: system.components.map((component) => ({ + connectionId: component.connectionId, + signature: connectionRowSignatureForID(connectionsByID, component.connectionId), + })), + primaryConnectionSignature: connectionRowSignatureForID(connectionsByID, system.id), + }); + const memberSubtitleFor = (member: ConnectionSystemMember): string => member.primary ? 'Primary node' : 'Cluster member'; @@ -361,6 +433,36 @@ interface ConnectionsLedgerSnapshot { } export const useConnectionsLedger = (): ConnectionsLedger => { + const rowCache = new Map(); + + const cacheRow = ( + activeKeys: Set, + key: string, + signature: string, + buildRow: () => InfrastructureSystemRow | null, + ): InfrastructureSystemRow | null => { + activeKeys.add(key); + const cached = rowCache.get(key); + if (cached?.signature === signature) { + return cached.row; + } + const row = buildRow(); + if (!row) { + rowCache.delete(key); + return null; + } + rowCache.set(key, { signature, row }); + return row; + }; + + const pruneRowCache = (activeKeys: Set) => { + for (const key of rowCache.keys()) { + if (!activeKeys.has(key)) { + rowCache.delete(key); + } + } + }; + const [resource, { refetch }] = createResource( async () => { const response = await ConnectionsAPI.list(); @@ -389,19 +491,41 @@ export const useConnectionsLedger = (): ConnectionsLedger => { const rows = createMemo(() => { const allConnections = connections(); const systems = snapshot().systems ?? []; + const activeCacheKeys = new Set(); + const standaloneRows = () => + allConnections + .map((connection) => + cacheRow( + activeCacheKeys, + `connection:${connection.id}`, + connectionRowSignature(connection), + () => connectionToRow(connection), + ), + ) + .filter((row): row is InfrastructureSystemRow => Boolean(row)); + if (systems.length === 0) { - return allConnections.map(connectionToRow); + const rows = standaloneRows(); + pruneRowCache(activeCacheKeys); + return rows; } const byID = new Map(allConnections.map((connection) => [connection.id, connection])); const groupedRows = systems - .map((system) => systemToRow(system, byID)) + .map((system) => + cacheRow(activeCacheKeys, `system:${system.id}`, systemRowSignature(system, byID), () => + systemToRow(system, byID), + ), + ) .filter((row): row is InfrastructureSystemRow => Boolean(row)); if (groupedRows.length === 0) { - return allConnections.map(connectionToRow); + const rows = standaloneRows(); + pruneRowCache(activeCacheKeys); + return rows; } + pruneRowCache(activeCacheKeys); return groupedRows; }); const findById = (id: string) => connections().find((conn) => conn.id === id);