diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 16175a4f5..72c8b1a2a 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,42 +1,79 @@ { "version": 1, - "base_sha": "39e8fe883471e48e265fc55fcc3e173f042647f8", - "verified_at": "2026-08-04T14:06:47Z", + "base_sha": "4f2efaec987d32e7b889990bf290e279455195b1", + "verified_at": "2026-08-04T16:32:07Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx", - "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx", - "frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts" + "frontend-modern/src/components/Alerts/ResourceTable.tsx", + "frontend-modern/src/components/Docker/SwarmServicesDrawer.tsx", + "frontend-modern/src/components/Kubernetes/K8sDeploymentsDrawer.tsx", + "frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx", + "frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx", + "frontend-modern/src/components/Settings/ReportingPanel.tsx", + "frontend-modern/src/components/Settings/UpdateHistorySection.tsx", + "frontend-modern/src/components/Storage/StoragePoolDetail.tsx", + "frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx", + "frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx", + "frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx", + "frontend-modern/src/features/alerts/AlertHistoryTableGroupRow.tsx", + "frontend-modern/src/features/alerts/AlertHistoryTableSection.tsx", + "frontend-modern/src/index.css" ], "content_sha256": { - "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "e8e18266f97f875318d79e9c2402e57d4811f6ef6453e2438d1bbffb1b676311", - "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "c73d79d7ab1d72dd021d759f90fb119a50e537689eb74980c8a7e9936e9b40f0", - "frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts": "3118d8ce7ed1f01793d7ecde38d633600ceb72cb4fe05afc93abfe9317f20233" + "frontend-modern/src/components/Alerts/ResourceTable.tsx": "e6940ca649aed4634516428b19e9820133e37831c0948153b59e3a055d1063f7", + "frontend-modern/src/components/Docker/SwarmServicesDrawer.tsx": "c96645048577e92abc45ac0004701464015cfa911d302a8582b1a34d234243a2", + "frontend-modern/src/components/Kubernetes/K8sDeploymentsDrawer.tsx": "b945d48523aa09a8a7ef51627879b1d5bb4b9f3a2bf120ae59c1699b0faf49d9", + "frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx": "d5e58f2d553dab251c9ca2515bc2298bef760bcf81ff091a7a50aa16fc0d6b21", + "frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx": "2582b69e4ede3feaf2a7912645034c5b0a5a1b00d30ba45538651bc188c681ac", + "frontend-modern/src/components/Settings/ReportingPanel.tsx": "7c8b322a6a42f00f53d72493c104b27e22dfcbe7265500037814a86ee85cf513", + "frontend-modern/src/components/Settings/UpdateHistorySection.tsx": "ea401483b8141822bb2f68aa5bf19635acd3a9f357a98034c87e1be3b9d7e757", + "frontend-modern/src/components/Storage/StoragePoolDetail.tsx": "facfddb199d1030939ccb37c9ac7a6f364254ec026d0c13c9bb5e28e5f73b8b6", + "frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx": "b91275a071e211e0c82710861aaa05cfaff3df65a0fa1648f5227f6994e2e303", + "frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx": "724df36aaff5e1850c0ed7b76d5c9e75e64dc322786875f08980ffc53ba9efd8", + "frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx": "042af5a38ce54af3388a51b4668059d49a2f3dfb0ffbaaebb989878b3cca2aa1", + "frontend-modern/src/features/alerts/AlertHistoryTableGroupRow.tsx": "2261f572a0b4923a493aa3f48897b9105621cf9884e8ad18d5eee6e20ee43ad5", + "frontend-modern/src/features/alerts/AlertHistoryTableSection.tsx": "927de809988a5c9f5e001943f3da9e6990373d18950716afb069eb13d53a78ee", + "frontend-modern/src/index.css": "2e4016eb83b6d2d8ebac6f8dd6e4422e5754ac25ceafc912181a1acd6edce61c" }, "routes": [ - "/proxmox/backups" + "/alerts/history", + "/alerts/thresholds", + "/kubernetes/overview", + "/docker/swarm", + "/settings/infrastructure/agent-doctor", + "/settings/reporting", + "/settings/updates", + "/proxmox/storage?tab=pools&source=proxmox-all" ], "viewports": [ - { - "width": 1280, - "height": 800 - }, - { - "width": 390, - "height": 844 - } + { "width": 375, "height": 812 }, + { "width": 540, "height": 900 }, + { "width": 640, "height": 900 }, + { "width": 768, "height": 900 }, + { "width": 1024, "height": 900 }, + { "width": 1280, "height": 900 }, + { "width": 1440, "height": 900 }, + { "width": 1680, "height": 1050 }, + { "width": 1920, "height": 1080 } ], "states": [ - "Resolved coverage table with attention, unprotected, unknown, and not-evaluated posture rows", - "Task-only workloads shown as unprotected with no restore point", - "Unknown-only filter showing one genuinely indeterminate canonical workload", - "Desktop and compact responsive coverage-table layouts" + "Mock-data alert history grouped cards on phone and progressively disclosed tablet and desktop columns", + "Expanded alert incident timeline and resource incident action state at phone width", + "Alert thresholds resource editors in card and table modes based on actual content-pane width", + "Kubernetes cluster inline drawer with Deployments and Namespaces tabs at phone, tablet, and desktop widths", + "Infrastructure Agent Doctor summary table and expanded critical-agent details", + "Proxmox storage pool detail expanded at phone width", + "Reporting, update history, and diagnostics system-page responsive matrix including empty mock states", + "Docker Swarm route empty mock state; responsive service drawer implementation covered by component tests" ], "interactions": [ - "Signed in to the managed local preview and opened Proxmox Backups", - "Selected the Unknown posture filter and confirmed the result count", - "Cleared the posture filter", - "Inspected desktop and 390x844 responsive layouts", - "Checked browser console warnings and errors" + "Opened alert history Timeline, Resource, and Patrol actions and checked expanded content geometry", + "Compared alert-history visible columns at 375, 640, 768, 1024, 1280, 1440, and 1680 pixels", + "Expanded the Production EU Kubernetes cluster and switched between Deployments and Namespaces", + "Confirmed Kubernetes deployment and namespace actions remain reachable without horizontal scrolling", + "Expanded the critical Agent Doctor row and inspected the full detail state", + "Expanded west-c-service-pool on Proxmox storage and checked the narrow detail layout", + "Ran 48 route-and-viewport system-page overflow checks and a dedicated thresholds matrix through 1920 pixels", + "Checked table, page, drawer, and expanded-card client widths against scroll widths after each interaction" ] } diff --git a/frontend-modern/src/components/Alerts/ResourceTable.test.tsx b/frontend-modern/src/components/Alerts/ResourceTable.test.tsx index 2829cc251..2774012d1 100644 --- a/frontend-modern/src/components/Alerts/ResourceTable.test.tsx +++ b/frontend-modern/src/components/Alerts/ResourceTable.test.tsx @@ -136,7 +136,11 @@ vi.mock('lucide-solid/icons/x', () => ({ default: (props: any) => , })); -import { ResourceTable, type Resource } from './ResourceTable'; +import { + ResourceTable, + getAlertResourceTableDesktopMinWidth, + type Resource, +} from './ResourceTable'; // --- Helpers --- function makeResource(overrides: Partial = {}): Resource { @@ -149,6 +153,17 @@ function makeResource(overrides: Partial = {}): Resource { }; } +describe('getAlertResourceTableDesktopMinWidth', () => { + it('keeps progressively denser editors in the card layout until their container can support them', () => { + expect(getAlertResourceTableDesktopMinWidth(4)).toBe(544); + expect(getAlertResourceTableDesktopMinWidth(6)).toBe(768); + expect(getAlertResourceTableDesktopMinWidth(8)).toBe(1024); + expect(getAlertResourceTableDesktopMinWidth(10)).toBe(1216); + expect(getAlertResourceTableDesktopMinWidth(13)).toBe(1728); + expect(getAlertResourceTableDesktopMinWidth(20)).toBe(Number.POSITIVE_INFINITY); + }); +}); + interface DefaultProps { title: string; resources: Resource[]; diff --git a/frontend-modern/src/components/Alerts/ResourceTable.tsx b/frontend-modern/src/components/Alerts/ResourceTable.tsx index fcdb701cb..ce6f2406e 100644 --- a/frontend-modern/src/components/Alerts/ResourceTable.tsx +++ b/frontend-modern/src/components/Alerts/ResourceTable.tsx @@ -1,4 +1,4 @@ -import { Show } from 'solid-js'; +import { Show, createMemo } from 'solid-js'; import X from 'lucide-solid/icons/x'; import { useBreakpoint } from '@/hooks/useBreakpoint'; import type { Alert } from '@/types/api'; @@ -8,6 +8,7 @@ import { getAlertBulkEditOpenLabel, } from '@/utils/alertBulkEditPresentation'; import { ActionIconButton } from '@/components/shared/Button'; +import { useObservedElementWidth } from '@/hooks/useObservedElementWidth'; import { useAlertResourceTableState } from './useAlertResourceTableState'; import type { GroupHeaderMeta, Resource } from '@/features/alerts/thresholds/tableTypes'; import { AlertResourceTableDesktop } from './AlertResourceTableDesktop'; @@ -70,8 +71,18 @@ export interface ResourceTableProps { export type OfflineState = 'off' | 'warning' | 'critical'; +export const getAlertResourceTableDesktopMinWidth = (totalColumns: number): number => { + if (totalColumns <= 4) return 544; + if (totalColumns <= 6) return 768; + if (totalColumns <= 8) return 1024; + if (totalColumns <= 10) return 1216; + if (totalColumns <= 14) return 1728; + return Number.POSITIVE_INFINITY; +}; + export function ResourceTable(props: ResourceTableProps) { const { isMobile } = useBreakpoint(); + const observedWidth = useObservedElementWidth(); const { activeMetricInput, setActiveMetricInput, @@ -91,11 +102,23 @@ export function ResourceTable(props: ResourceTableProps) { globalDefaults: props.globalDefaults, factoryDefaults: props.factoryDefaults, }); + const totalColumnCount = createMemo( + () => + props.columns.length + + 3 + + (props.showOfflineAlertsColumn ? 1 : 0) + + (props.onBulkEdit ? 1 : 0), + ); + const useCardLayout = createMemo(() => { + if (isMobile()) return true; + const width = observedWidth.width(); + return width !== null && width < getAlertResourceTableDesktopMinWidth(totalColumnCount()); + }); return ( - <> +
- + ); } diff --git a/frontend-modern/src/components/Docker/SwarmServicesDrawer.tsx b/frontend-modern/src/components/Docker/SwarmServicesDrawer.tsx index 9dd4f8551..1fb969c03 100644 --- a/frontend-modern/src/components/Docker/SwarmServicesDrawer.tsx +++ b/frontend-modern/src/components/Docker/SwarmServicesDrawer.tsx @@ -269,19 +269,25 @@ export const SwarmServicesDrawer: Component<{ cluster: string; swarm?: SwarmInfo } > - +
{drawerPresentation.serviceColumnLabel} - + - + {drawerPresentation.imageColumnLabel} - + {drawerPresentation.modeColumnLabel} {drawerPresentation.runningColumnLabel} - + - + {drawerPresentation.portsColumnLabel} @@ -331,21 +341,21 @@ export const SwarmServicesDrawer: Component<{ cluster: string; swarm?: SwarmInfo {image()} {mode()} @@ -360,14 +370,14 @@ export const SwarmServicesDrawer: Component<{ cluster: string; swarm?: SwarmInfo {ports()} diff --git a/frontend-modern/src/components/Kubernetes/K8sDeploymentsDrawer.tsx b/frontend-modern/src/components/Kubernetes/K8sDeploymentsDrawer.tsx index a91a23643..937c7c1a3 100644 --- a/frontend-modern/src/components/Kubernetes/K8sDeploymentsDrawer.tsx +++ b/frontend-modern/src/components/Kubernetes/K8sDeploymentsDrawer.tsx @@ -24,6 +24,7 @@ import { buildKubernetesPath } from '@/routing/resourceLinks'; import { PLATFORM_TABLE_BODY_CLASS, PLATFORM_TABLE_HEADER_ROW_CLASS, + PlatformResponsiveTableLabel, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, } from '@/features/platformPage/sharedPlatformPage'; @@ -242,22 +243,24 @@ export const K8sDeploymentsDrawer: Component<{ } > -
+
{drawerPresentation.deploymentColumnLabel} - + {drawerPresentation.desiredColumnLabel} {drawerPresentation.updatedColumnLabel} @@ -303,19 +306,19 @@ export const K8sDeploymentsDrawer: Component<{ {desired()} {updated()} @@ -323,6 +326,7 @@ export const K8sDeploymentsDrawer: Component<{ class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`} > {ready()} + /{desired()} diff --git a/frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx b/frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx index 84a5043ed..091a2a95a 100644 --- a/frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx +++ b/frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx @@ -18,6 +18,7 @@ import { buildKubernetesPath } from '@/routing/resourceLinks'; import { PLATFORM_TABLE_BODY_CLASS, PLATFORM_TABLE_HEADER_ROW_CLASS, + PlatformResponsiveTableLabel, formatPlatformTableIntegerValue, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, @@ -148,23 +149,25 @@ export const K8sNamespacesDrawer: Component<{ } > -
+
{drawerPresentation.namespaceColumnLabel} {drawerPresentation.podsColumnLabel} {drawerPresentation.deploymentsColumnLabel} - + {drawerPresentation.actionsColumnLabel} @@ -225,18 +228,26 @@ export const K8sNamespacesDrawer: Component<{
diff --git a/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx b/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx index 4d9db0c12..2ac673008 100644 --- a/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx @@ -11,6 +11,7 @@ import { } from '@/components/shared/Table'; import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow'; import { copyToClipboard } from '@/utils/clipboard'; +import { formatRelativeTime } from '@/utils/format'; import { notificationStore } from '@/stores/notifications'; import { getUnifiedAgentClipboardCopyErrorMessage, @@ -334,26 +335,27 @@ export const InfrastructureAgentDoctorPage: Component
-
+
Agent - + Status - + - + Target - Last seen + Seen + Last seen @@ -362,6 +364,11 @@ export const InfrastructureAgentDoctorPage: Component { const status = () => STATUS_PRESENTATION[target.status]; const lastSeen = () => formatLastSeen(target.lastSeen); + const lastSeenRelative = () => + formatRelativeTime(target.lastSeen ?? undefined, { + compact: true, + emptyText: '—', + }); const command = () => target.connection && target.commandPlatform ? operations.getAgentConnectionUpgradeCommand( @@ -405,7 +412,7 @@ export const InfrastructureAgentDoctorPage: Component - + @@ -415,14 +422,15 @@ export const InfrastructureAgentDoctorPage: Component - + - + {target.expectedVersion || '—'} - - {lastSeen() || '—'} + + {lastSeenRelative()} + {lastSeen() || '—'} diff --git a/frontend-modern/src/components/Settings/ReportingPanel.tsx b/frontend-modern/src/components/Settings/ReportingPanel.tsx index 7a740783b..d3f9ef753 100644 --- a/frontend-modern/src/components/Settings/ReportingPanel.tsx +++ b/frontend-modern/src/components/Settings/ReportingPanel.tsx @@ -349,26 +349,23 @@ export function ReportingPanel() { } > -
-
- - - - - - - - - +
+
- - - - - - - + + + + + + + @@ -388,19 +385,19 @@ export function ReportingPanel() { {reportScheduleCadenceLabel(schedule)}
NameCadenceScopeDeliveryLast runEnabledActionsNameCadence + Scope + + Delivery + EnabledActions
{reportScheduleScopeLabel(schedule)} {reportScheduleDeliveryLabel(schedule)}
{reportScheduleLastRunLabel(schedule)}
diff --git a/frontend-modern/src/components/Settings/UpdateHistorySection.tsx b/frontend-modern/src/components/Settings/UpdateHistorySection.tsx index 66b2c29f4..30f2d42ab 100644 --- a/frontend-modern/src/components/Settings/UpdateHistorySection.tsx +++ b/frontend-modern/src/components/Settings/UpdateHistorySection.tsx @@ -112,22 +112,15 @@ export const UpdateHistorySection: Component = () => {

No updates have been applied through Pulse yet.

} > -
- - - - - - - - +
+
- - - - - + + + + + @@ -137,7 +130,9 @@ export const UpdateHistorySection: Component = () => { - +
WhenActionVersionResultWhenVersionResult
{formatTimestamp(entry.timestamp)} {actionLabel(entry)} = (props) => {
Datasets ({zfsSummary()!.datasets.length})
-
- +
+
- - + + @@ -214,11 +218,11 @@ export const StoragePoolDetail: Component = (props) => { -
Dataset Used AvailableReferencedMountpoint + Referenced +
{dataset.availableLabel} + {dataset.referencedLabel} {dataset.mountpoint || diff --git a/frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx b/frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx new file mode 100644 index 000000000..431253ddb --- /dev/null +++ b/frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx @@ -0,0 +1,81 @@ +import { Show } from 'solid-js'; + +import { InvestigateAlertButton } from '@/components/Alerts/InvestigateAlertButton'; +import { getAlertResourceIncidentViewTitle } from '@/utils/alertIncidentPresentation'; + +import type { AlertHistoryState } from './useAlertHistoryState'; + +type AlertHistoryAlert = ReturnType[number]['alerts'][number]; + +interface AlertHistoryItemActionsProps { + alert: AlertHistoryAlert; + state: AlertHistoryState; + class?: string; +} + +export function AlertHistoryItemActions(props: AlertHistoryItemActionsProps) { + const rowKey = () => props.state.getIncidentRowKey(props.alert); + + return ( +
+ + + + + + + + + +
+ ); +} diff --git a/frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx b/frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx new file mode 100644 index 000000000..61207eb48 --- /dev/null +++ b/frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx @@ -0,0 +1,142 @@ +import { For, Show } from 'solid-js'; + +import { IncidentTimelinePanel } from '@/components/Alerts/IncidentTimelinePanel'; +import { getAlertHistoryResourceTypeBadgeClass } from '@/utils/alertHistoryPresentation'; +import { + getAlertHistoryStatusPresentation, + getAlertIncidentLevelBadgeClass, +} from '@/utils/alertIncidentPresentation'; + +import { AlertHistoryItemActions } from './AlertHistoryItemActions'; +import { getGroupSummaryLabel } from './AlertHistoryTableGroupRow'; +import type { AlertHistoryState } from './useAlertHistoryState'; + +interface AlertHistoryMobileListProps { + state: AlertHistoryState; +} + +export function AlertHistoryMobileList(props: AlertHistoryMobileListProps) { + return ( +
+ + {(group) => ( +
+
+

+ {group.label} +

+ {getGroupSummaryLabel(group)} +
+ +
+ + {(alert) => { + const rowKey = () => props.state.getIncidentRowKey(alert); + const status = () => getAlertHistoryStatusPresentation(alert.status); + + return ( +
+
+
+

+ {alert.resourceName} +

+ + {alert.title} + +
+
+ + {alert.severity} + + {status().label} +
+
+ +

+ {alert.description} +

+ +
+
+
Time
+
+ {new Date(alert.startTime).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + })} +
+
+
+
Duration
+
{alert.duration}
+
+
+
Node
+
+ {alert.nodeDisplayName || alert.node || '—'} +
+
+
+ + + + +
+ + props.state.setIncidentNoteDraft(rowKey(), value) + } + noteSaving={props.state.incidentNoteSaving().has(rowKey())} + onSaveNote={() => { + void props.state.saveIncidentNote( + rowKey(), + alert.id, + alert.startTime, + ); + }} + onRetry={() => { + void props.state.loadIncidentTimeline( + rowKey(), + alert.id, + alert.startTime, + ); + }} + /> +
+
+
+ ); + }} +
+
+
+ )} +
+
+ ); +} diff --git a/frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx b/frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx index 55bbfbfaa..2eb297237 100644 --- a/frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx +++ b/frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx @@ -1,16 +1,15 @@ import { Show } from 'solid-js'; import { IncidentTimelinePanel } from '@/components/Alerts/IncidentTimelinePanel'; -import { InvestigateAlertButton } from '@/components/Alerts/InvestigateAlertButton'; import { TableCell, TableRow } from '@/components/shared/Table'; import { getPlatformTableCellClassForKind } from '@/features/platformPage/sharedPlatformPage'; import { getAlertHistoryResourceTypeBadgeClass } from '@/utils/alertHistoryPresentation'; import { getAlertHistoryStatusPresentation, getAlertIncidentLevelBadgeClass, - getAlertResourceIncidentViewTitle, } from '@/utils/alertIncidentPresentation'; +import { AlertHistoryItemActions } from './AlertHistoryItemActions'; import type { AlertHistoryState } from './useAlertHistoryState'; type AlertHistoryAlert = ReturnType[number]['alerts'][number]; @@ -30,7 +29,7 @@ export function AlertHistoryTableAlertRow(props: AlertHistoryTableAlertRowProps) class={`border-b border-border hover:bg-surface-hover ${historyStatusPresentation().rowClassName}`} > {new Date(props.alert.startTime).toLocaleTimeString('en-US', { hour: '2-digit', @@ -45,7 +44,7 @@ export function AlertHistoryTableAlertRow(props: AlertHistoryTableAlertRowProps) {props.alert.resourceName} - +
+ + } > diff --git a/frontend-modern/src/features/alerts/__tests__/AlertHistoryMobileList.test.tsx b/frontend-modern/src/features/alerts/__tests__/AlertHistoryMobileList.test.tsx new file mode 100644 index 000000000..5c3b6a14a --- /dev/null +++ b/frontend-modern/src/features/alerts/__tests__/AlertHistoryMobileList.test.tsx @@ -0,0 +1,80 @@ +import { fireEvent, render, screen } from '@solidjs/testing-library'; +import { describe, expect, it, vi } from 'vitest'; + +import { AlertHistoryMobileList } from '../AlertHistoryMobileList'; +import type { AlertHistoryState } from '../useAlertHistoryState'; + +function createState() { + const toggleIncidentTimeline = vi.fn(); + const openResourceIncidentPanel = vi.fn(); + const alert = { + id: 'alert-1', + source: 'alert', + resourceId: 'node-1', + resourceName: 'pve-production-01', + resourceType: 'node', + title: 'Backup', + description: 'Backup failed after the target became unavailable.', + severity: 'warning', + status: 'resolved', + duration: '14m', + startTime: '2026-08-04T14:30:00.000Z', + node: 'pve-production-01', + nodeDisplayName: 'Production cluster', + rawAlertType: 'backup_failed', + }; + + const state = { + groupedAlerts: () => [ + { + label: 'Today (August 4th)', + fullLabel: 'Today, August 4th 2026', + alerts: [alert], + }, + ], + getIncidentRowKey: () => 'alert-1-row', + expandedIncidents: () => new Set(), + incidentLoading: () => ({}), + incidentErrors: () => ({}), + incidentTimelines: () => ({}), + historyIncidentEventFilters: () => [], + setHistoryIncidentEventFilters: vi.fn(), + incidentNoteDrafts: () => ({}), + setIncidentNoteDraft: vi.fn(), + incidentNoteSaving: () => new Set(), + saveIncidentNote: vi.fn(), + loadIncidentTimeline: vi.fn(), + toggleIncidentTimeline, + openResourceIncidentPanel, + } as unknown as AlertHistoryState; + + return { openResourceIncidentPanel, state, toggleIncidentTimeline }; +} + +describe('AlertHistoryMobileList', () => { + it('keeps the operator context and actions together in a phone-sized card', async () => { + const { openResourceIncidentPanel, state, toggleIncidentTimeline } = createState(); + + const { container } = render(() => ); + + expect(container.querySelector('[data-testid="alert-history-mobile-list"]')).toHaveClass( + 'md:hidden', + ); + expect(screen.getByRole('heading', { name: 'pve-production-01' })).toBeInTheDocument(); + expect(screen.getByText('Backup failed after the target became unavailable.')).toBeVisible(); + expect(screen.getByText('14m')).toBeVisible(); + expect(screen.getByText('Production cluster')).toBeVisible(); + expect(screen.getByText('warning')).toBeVisible(); + expect(screen.getByText('resolved')).toBeVisible(); + + await fireEvent.click(screen.getByRole('button', { name: 'Timeline' })); + expect(toggleIncidentTimeline).toHaveBeenCalledWith( + 'alert-1-row', + 'alert-1', + '2026-08-04T14:30:00.000Z', + ); + + await fireEvent.click(screen.getByRole('button', { name: 'Resource' })); + expect(openResourceIncidentPanel).toHaveBeenCalledWith('node-1', 'pve-production-01'); + }); +}); diff --git a/frontend-modern/src/index.css b/frontend-modern/src/index.css index be601b044..0e8938c56 100644 --- a/frontend-modern/src/index.css +++ b/frontend-modern/src/index.css @@ -237,6 +237,69 @@ } } + /* + Alert history has a persistent navigation rail, so its useful table width + can be much narrower than the viewport. Time and duration add helpful + context once the table owns 800px, while type and node remain reserved for + the 1024px full-detail layout handled by the canonical md table priority. + */ + .table-scroll-shell :is(th, td).alert-history-context-column { + display: none; + } + + /* + These secondary fields are useful in a roomy drawer or settings pane, but + they must not displace identity, health, readiness, or row actions. Their + shared 800px reveal point is based on the table shell rather than viewport. + */ + .table-scroll-shell :is(th, td).agent-doctor-target-column { + display: none; + } + + .table-scroll-shell :is(th, td).k8s-deployment-detail-column { + display: none; + } + + .table-scroll-shell :is(th, td).swarm-service-detail-column { + display: none; + } + + .table-scroll-shell :is(th, td).report-schedule-detail-column, + .table-scroll-shell :is(th, td).storage-dataset-referenced-column { + display: none; + } + + .k8s-deployment-ready-total { + display: inline; + } + + @container (min-width: 50rem) { + .table-scroll-shell :is(th, td).alert-history-context-column { + display: table-cell; + } + + .table-scroll-shell :is(th, td).agent-doctor-target-column { + display: table-cell; + } + + .table-scroll-shell :is(th, td).k8s-deployment-detail-column { + display: table-cell; + } + + .table-scroll-shell :is(th, td).swarm-service-detail-column { + display: table-cell; + } + + .table-scroll-shell :is(th, td).report-schedule-detail-column, + .table-scroll-shell :is(th, td).storage-dataset-referenced-column { + display: table-cell; + } + + .k8s-deployment-ready-total { + display: none; + } + } + .app-scroll-shell { scrollbar-gutter: stable; } diff --git a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts index 7f356271b..926faaba1 100644 --- a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts +++ b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts @@ -15,6 +15,7 @@ import alertAcknowledgementStateSource from '@/features/alerts/useAlertAcknowled import alertHistoryAdministrationCardSource from '@/features/alerts/AlertHistoryAdministrationCard.tsx?raw'; import alertHistoryFiltersCardSource from '@/features/alerts/AlertHistoryFiltersCard.tsx?raw'; import alertHistoryFrequencyCardSource from '@/features/alerts/AlertHistoryFrequencyCard.tsx?raw'; +import alertHistoryItemActionsSource from '@/features/alerts/AlertHistoryItemActions.tsx?raw'; import alertHistoryTableAlertRowSource from '@/features/alerts/AlertHistoryTableAlertRow.tsx?raw'; import alertHistoryTableGroupRowSource from '@/features/alerts/AlertHistoryTableGroupRow.tsx?raw'; import alertHistoryTableSectionSource from '@/features/alerts/AlertHistoryTableSection.tsx?raw'; @@ -521,7 +522,8 @@ describe('tab path helpers', () => { expect(alertResourceTableDesktopSource).toContain('getGroupedTableRowCellClass'); expect(alertHistoryTableAlertRowSource).toContain('export function AlertHistoryTableAlertRow'); expect(alertHistoryTableAlertRowSource).toContain('IncidentTimelinePanel'); - expect(alertHistoryTableAlertRowSource).toContain('InvestigateAlertButton'); + expect(alertHistoryTableAlertRowSource).toContain('AlertHistoryItemActions'); + expect(alertHistoryItemActionsSource).toContain('InvestigateAlertButton'); expect(alertHistoryAdministrationCardSource).toContain( 'export function AlertHistoryAdministrationCard', ); diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 67dfe168b..b898c6472 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -332,6 +332,7 @@ import alertAcknowledgementStateSource from '@/features/alerts/useAlertAcknowled import alertHistoryAdministrationCardSource from '@/features/alerts/AlertHistoryAdministrationCard.tsx?raw'; import alertHistoryFiltersCardSource from '@/features/alerts/AlertHistoryFiltersCard.tsx?raw'; import alertHistoryFrequencyCardSource from '@/features/alerts/AlertHistoryFrequencyCard.tsx?raw'; +import alertHistoryItemActionsSource from '@/features/alerts/AlertHistoryItemActions.tsx?raw'; import alertHistoryTableAlertRowSource from '@/features/alerts/AlertHistoryTableAlertRow.tsx?raw'; import alertHistoryTableGroupRowSource from '@/features/alerts/AlertHistoryTableGroupRow.tsx?raw'; import alertHistoryTableSectionSource from '@/features/alerts/AlertHistoryTableSection.tsx?raw'; @@ -2838,7 +2839,8 @@ describe('frontend resource type boundaries', () => { expect(alertHistoryTableAlertRowSource).toContain('getAlertHistoryStatusPresentation'); expect(alertHistoryTableAlertRowSource).toContain('getAlertHistoryResourceTypeBadgeClass'); expect(alertHistoryTableAlertRowSource).toContain('IncidentTimelinePanel'); - expect(alertHistoryTableAlertRowSource).toContain('InvestigateAlertButton'); + expect(alertHistoryTableAlertRowSource).toContain('AlertHistoryItemActions'); + expect(alertHistoryItemActionsSource).toContain('InvestigateAlertButton'); expect(alertHistoryAdministrationCardSource).toContain('getAlertAdministrationSectionTitle'); expect(alertHistoryAdministrationCardSource).toContain( 'getAlertAdministrationSectionDescription',