fix(ui): make operator tables container-responsive

Contract-Neutral: Responsive presentation only; no subsystem behavior or public contract changes.
This commit is contained in:
rcourtman
2026-08-04 17:33:41 +01:00
parent 4f2efaec98
commit aed20ba7bc
19 changed files with 668 additions and 242 deletions
+63 -26
View File
@@ -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"
]
}
@@ -136,7 +136,11 @@ vi.mock('lucide-solid/icons/x', () => ({
default: (props: any) => <svg data-testid="x-icon" class={props.class} />,
}));
import { ResourceTable, type Resource } from './ResourceTable';
import {
ResourceTable,
getAlertResourceTableDesktopMinWidth,
type Resource,
} from './ResourceTable';
// --- Helpers ---
function makeResource(overrides: Partial<Resource> = {}): Resource {
@@ -149,6 +153,17 @@ function makeResource(overrides: Partial<Resource> = {}): 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[];
@@ -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 (
<>
<div ref={observedWidth.setElement} class="min-w-0">
<Show
when={!isMobile()}
when={!useCardLayout()}
fallback={
<AlertResourceTableMobile
table={props}
@@ -151,6 +174,6 @@ export function ResourceTable(props: ResourceTableProps) {
</div>
</div>
</Show>
</>
</div>
);
}
@@ -269,19 +269,25 @@ export const SwarmServicesDrawer: Component<{ cluster: string; swarm?: SwarmInfo
}
>
<Card padding="none" tone="card" class="overflow-hidden">
<Table class="min-w-full table-fixed text-xs md:min-w-[960px]">
<Table class="min-w-0 table-fixed text-xs">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[18%]`}>
{drawerPresentation.serviceColumnLabel}
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[13%]`}>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:w-[13%] sm:table-cell`}
>
{drawerPresentation.stackColumnLabel}
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[22%]`}>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} swarm-service-detail-column md:w-[22%]`}
>
{drawerPresentation.imageColumnLabel}
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[10%]`}>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} swarm-service-detail-column md:w-[10%]`}
>
{drawerPresentation.modeColumnLabel}
</TableHead>
<TableHead
@@ -294,10 +300,14 @@ export const SwarmServicesDrawer: Component<{ cluster: string; swarm?: SwarmInfo
>
{drawerPresentation.runningColumnLabel}
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[12%]`}>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:w-[12%] sm:table-cell`}
>
{drawerPresentation.updateColumnLabel}
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[9%]`}>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} swarm-service-detail-column md:w-[9%]`}
>
{drawerPresentation.portsColumnLabel}
</TableHead>
</TableRow>
@@ -331,21 +341,21 @@ export const SwarmServicesDrawer: Component<{ cluster: string; swarm?: SwarmInfo
</div>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content sm:table-cell`}
>
<span class="block truncate" title={stack()}>
{stack()}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
class={`${getPlatformTableCellClassForKind('text')} swarm-service-detail-column text-base-content`}
>
<span class="block truncate" title={image()}>
{image()}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
class={`${getPlatformTableCellClassForKind('text')} swarm-service-detail-column text-base-content`}
>
{mode()}
</TableCell>
@@ -360,14 +370,14 @@ export const SwarmServicesDrawer: Component<{ cluster: string; swarm?: SwarmInfo
<PlatformTableNumberValue value={running()} />
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content sm:table-cell`}
>
<span class="block truncate" title={update()}>
{update()}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
class={`${getPlatformTableCellClassForKind('text')} swarm-service-detail-column text-base-content`}
>
<span class="block truncate font-mono text-[11px]" title={ports()}>
{ports()}
@@ -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<{
}
>
<Card padding="none" tone="card" class="overflow-hidden">
<Table class="min-w-full table-fixed text-xs md:min-w-[820px]">
<Table class="min-w-0 table-fixed text-xs">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[26%]`}>
{drawerPresentation.deploymentColumnLabel}
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[16%]`}>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:w-[16%] sm:table-cell`}
>
{drawerPresentation.namespaceColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[10%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')} k8s-deployment-detail-column md:w-[10%]`}
>
{drawerPresentation.desiredColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[10%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')} k8s-deployment-detail-column md:w-[10%]`}
>
{drawerPresentation.updatedColumnLabel}
</TableHead>
@@ -303,19 +306,19 @@ export const K8sDeploymentsDrawer: Component<{
</div>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content sm:table-cell`}
>
<span class="block truncate" title={ns()}>
{ns()}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
class={`${getPlatformTableCellClassForKind('numeric-value')} k8s-deployment-detail-column text-base-content tabular-nums`}
>
{desired()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
class={`${getPlatformTableCellClassForKind('numeric-value')} k8s-deployment-detail-column text-base-content tabular-nums`}
>
{updated()}
</TableCell>
@@ -323,6 +326,7 @@ export const K8sDeploymentsDrawer: Component<{
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
>
{ready()}
<span class="k8s-deployment-ready-total">/{desired()}</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
@@ -332,10 +336,14 @@ export const K8sDeploymentsDrawer: Component<{
<TableCell class={getPlatformTableCellClassForKind('text')}>
<button
type="button"
aria-label={drawerPresentation.viewPodsLabel}
onClick={() => openPods(dep.kubernetes?.namespace)}
class="rounded-md border border-border bg-surface px-2 py-1 text-[11px] font-semibold text-base-content shadow-sm hover:bg-surface-hover"
>
{drawerPresentation.viewPodsLabel}
<PlatformResponsiveTableLabel
compact="Pods"
full={drawerPresentation.viewPodsLabel}
/>
</button>
</TableCell>
</TableRow>
@@ -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<{
}
>
<Card padding="none" tone="card" class="overflow-hidden">
<Table class="min-w-full table-fixed text-xs md:min-w-[760px]">
<Table class="min-w-0 table-fixed text-xs">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[22%]`}>
{drawerPresentation.namespaceColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[18%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[18%]`}
>
{drawerPresentation.podsColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[22%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[22%]`}
>
{drawerPresentation.deploymentsColumnLabel}
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[38%]`}>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-30 md:w-[38%]`}
>
{drawerPresentation.actionsColumnLabel}
</TableHead>
</TableRow>
@@ -225,18 +228,26 @@ export const K8sNamespacesDrawer: Component<{
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
aria-label={drawerPresentation.openPodsLabel}
onClick={() => openPods(row.namespace)}
class="rounded-md border border-border bg-surface px-2 py-1 text-[11px] font-semibold text-base-content shadow-sm hover:bg-surface-hover"
>
{drawerPresentation.openPodsLabel}
<PlatformResponsiveTableLabel
compact="Pods"
full={drawerPresentation.openPodsLabel}
/>
</button>
<Show when={props.onOpenDeployments}>
<button
type="button"
aria-label={drawerPresentation.viewDeploymentsLabel}
onClick={() => props.onOpenDeployments?.(row.namespace)}
class="rounded-md border border-border bg-surface px-2 py-1 text-[11px] font-semibold text-base-content shadow-sm hover:bg-surface-hover"
>
{drawerPresentation.viewDeploymentsLabel}
<PlatformResponsiveTableLabel
compact="Deployments"
full={drawerPresentation.viewDeploymentsLabel}
/>
</button>
</Show>
</div>
@@ -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<InfrastructureAgentDoctorP
</Show>
<div class="rounded-md border border-border bg-surface">
<Table class="w-full min-w-[760px] table-fixed text-sm">
<Table class="w-full min-w-0 table-fixed text-sm">
<TableHeader class="bg-surface-alt/60">
<TableRow>
<TableHead class="w-[26%] py-1.5 pl-3 pr-3 text-left text-[11px] font-medium text-muted whitespace-nowrap">
Agent
</TableHead>
<TableHead class="w-[15%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap">
<TableHead class="hidden w-[15%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap sm:table-cell">
System
</TableHead>
<TableHead class="w-[15%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap">
Status
</TableHead>
<TableHead class="w-[12%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap">
<TableHead class="hidden w-[12%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap sm:table-cell">
Reported
</TableHead>
<TableHead class="w-[12%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap">
<TableHead class="agent-doctor-target-column w-[12%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap">
Target
</TableHead>
<TableHead class="w-[20%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap">
Last seen
<span class="platform-table-label-compact">Seen</span>
<span class="platform-table-label-full">Last seen</span>
</TableHead>
</TableRow>
</TableHeader>
@@ -362,6 +364,11 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
{(target) => {
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<InfrastructureAgentDoctorP
</span>
</div>
</TableCell>
<TableCell class="px-3 py-1.5">
<TableCell class="hidden px-3 py-1.5 sm:table-cell">
<span class="truncate text-xs text-muted">{target.contextLabel}</span>
</TableCell>
<TableCell class="px-3 py-1.5">
@@ -415,14 +422,15 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
{status().label}
</span>
</TableCell>
<TableCell class="px-3 py-1.5 text-xs text-base-content">
<TableCell class="hidden px-3 py-1.5 text-xs text-base-content sm:table-cell">
{target.currentVersion || '—'}
</TableCell>
<TableCell class="px-3 py-1.5 text-xs text-base-content">
<TableCell class="agent-doctor-target-column px-3 py-1.5 text-xs text-base-content">
{target.expectedVersion || '—'}
</TableCell>
<TableCell class="px-3 py-1.5 text-xs text-muted">
{lastSeen() || '—'}
<TableCell class="px-3 py-1.5 text-xs text-muted" title={lastSeen() || ''}>
<span class="platform-table-label-compact">{lastSeenRelative()}</span>
<span class="platform-table-label-full">{lastSeen() || '—'}</span>
</TableCell>
</TableRow>
@@ -349,26 +349,23 @@ export function ReportingPanel() {
</div>
}
>
<div class="overflow-x-auto rounded-md border border-base-300">
<table class="w-full min-w-[980px] table-fixed text-left text-sm">
<colgroup>
<col class="w-[18%]" />
<col class="w-[16%]" />
<col class="w-[14%]" />
<col class="w-[14%]" />
<col class="w-[16%]" />
<col class="w-[8%]" />
<col class="w-[14%]" />
</colgroup>
<div class="table-scroll-shell overflow-x-auto rounded-md border border-base-300">
<table class="w-full min-w-0 table-fixed text-left text-sm">
<thead class="border-b border-base-300 bg-base-200/50 text-xs uppercase text-muted">
<tr>
<th class="px-3 py-2 font-semibold">Name</th>
<th class="px-3 py-2 font-semibold">Cadence</th>
<th class="px-3 py-2 font-semibold">Scope</th>
<th class="px-3 py-2 font-semibold">Delivery</th>
<th class="px-3 py-2 font-semibold">Last run</th>
<th class="px-3 py-2 font-semibold">Enabled</th>
<th class="px-3 py-2 text-right font-semibold">Actions</th>
<th class="w-[18%] px-3 py-2 font-semibold">Name</th>
<th class="w-[16%] px-3 py-2 font-semibold">Cadence</th>
<th class="report-schedule-detail-column w-[14%] px-3 py-2 font-semibold">
Scope
</th>
<th class="report-schedule-detail-column w-[14%] px-3 py-2 font-semibold">
Delivery
</th>
<th class="hidden w-[16%] px-3 py-2 font-semibold sm:table-cell">
Last run
</th>
<th class="w-[8%] px-3 py-2 font-semibold">Enabled</th>
<th class="w-[14%] px-3 py-2 text-right font-semibold">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-base-300">
@@ -388,19 +385,19 @@ export function ReportingPanel() {
{reportScheduleCadenceLabel(schedule)}
</td>
<td
class="truncate px-3 py-2 text-muted"
class="report-schedule-detail-column truncate px-3 py-2 text-muted"
title={reportScheduleScopeLabel(schedule)}
>
{reportScheduleScopeLabel(schedule)}
</td>
<td
class="truncate px-3 py-2 text-muted"
class="report-schedule-detail-column truncate px-3 py-2 text-muted"
title={reportScheduleDeliveryLabel(schedule)}
>
{reportScheduleDeliveryLabel(schedule)}
</td>
<td
class="truncate px-3 py-2 text-muted"
class="hidden truncate px-3 py-2 text-muted sm:table-cell"
title={schedule.last_error || ''}
>
<div class="truncate">{reportScheduleLastRunLabel(schedule)}</div>
@@ -112,22 +112,15 @@ export const UpdateHistorySection: Component = () => {
<p class="text-sm text-muted">No updates have been applied through Pulse yet.</p>
}
>
<div class="overflow-x-auto rounded-md border border-border">
<table class="w-full min-w-[560px] table-fixed text-left text-sm">
<colgroup>
<col class="w-[26%]" />
<col class="w-[20%]" />
<col class="w-[24%]" />
<col class="w-[15%]" />
<col class="w-[15%]" />
</colgroup>
<div class="table-scroll-shell overflow-x-auto rounded-md border border-border">
<table class="w-full min-w-0 table-fixed text-left text-sm">
<thead class="border-b border-border bg-surface-alt text-xs uppercase text-muted">
<tr>
<th class="px-3 py-2 font-semibold">When</th>
<th class="px-3 py-2 font-semibold">Action</th>
<th class="px-3 py-2 font-semibold">Version</th>
<th class="px-3 py-2 font-semibold">Result</th>
<th class="px-3 py-2 text-right font-semibold"></th>
<th class="w-[26%] px-3 py-2 font-semibold">When</th>
<th class="hidden w-[20%] px-3 py-2 font-semibold sm:table-cell">Action</th>
<th class="w-[24%] px-3 py-2 font-semibold">Version</th>
<th class="w-[15%] px-3 py-2 font-semibold">Result</th>
<th class="w-[15%] px-3 py-2 text-right font-semibold"></th>
</tr>
</thead>
<tbody class="divide-y divide-border">
@@ -137,7 +130,9 @@ export const UpdateHistorySection: Component = () => {
<td class="truncate px-3 py-2 text-muted" title={entry.timestamp}>
{formatTimestamp(entry.timestamp)}
</td>
<td class="truncate px-3 py-2 text-base-content">{actionLabel(entry)}</td>
<td class="hidden truncate px-3 py-2 text-base-content sm:table-cell">
{actionLabel(entry)}
</td>
<td
class="truncate px-3 py-2 text-muted"
title={`${formatVersionLabel(entry.version_from)} to ${formatVersionLabel(entry.version_to)}`}
@@ -187,15 +187,19 @@ export const StoragePoolDetail: Component<StoragePoolDetailProps> = (props) => {
<div class="text-xs font-medium text-base-content">
Datasets ({zfsSummary()!.datasets.length})
</div>
<div class="overflow-x-auto rounded border border-border">
<table class="w-full min-w-[34rem] text-left text-xs">
<div class="table-scroll-shell overflow-x-auto rounded border border-border">
<table class="w-full min-w-0 table-fixed text-left text-xs">
<thead class="bg-surface-alt text-muted">
<tr>
<th class="px-2 py-1.5 font-medium">Dataset</th>
<th class="px-2 py-1.5 font-medium">Used</th>
<th class="px-2 py-1.5 font-medium">Available</th>
<th class="px-2 py-1.5 font-medium">Referenced</th>
<th class="px-2 py-1.5 font-medium">Mountpoint</th>
<th class="storage-dataset-referenced-column px-2 py-1.5 font-medium">
Referenced
</th>
<th class="hidden px-2 py-1.5 font-medium sm:table-cell">
Mountpoint
</th>
</tr>
</thead>
<tbody>
@@ -214,11 +218,11 @@ export const StoragePoolDetail: Component<StoragePoolDetailProps> = (props) => {
<td class="whitespace-nowrap px-2 py-1.5 font-mono">
{dataset.availableLabel}
</td>
<td class="whitespace-nowrap px-2 py-1.5 font-mono">
<td class="storage-dataset-referenced-column whitespace-nowrap px-2 py-1.5 font-mono">
{dataset.referencedLabel}
</td>
<td
class="max-w-48 truncate px-2 py-1.5 text-muted"
class="hidden max-w-48 truncate px-2 py-1.5 text-muted sm:table-cell"
title={dataset.mountpoint}
>
{dataset.mountpoint ||
@@ -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<AlertHistoryState['groupedAlerts']>[number]['alerts'][number];
interface AlertHistoryItemActionsProps {
alert: AlertHistoryAlert;
state: AlertHistoryState;
class?: string;
}
export function AlertHistoryItemActions(props: AlertHistoryItemActionsProps) {
const rowKey = () => props.state.getIncidentRowKey(props.alert);
return (
<div class={`flex flex-wrap items-center gap-1.5 ${props.class ?? ''}`.trim()}>
<Show when={props.alert.source === 'alert'}>
<button
type="button"
class="rounded-md border border-border px-2 py-1 text-[10px] text-muted hover:bg-surface-hover"
onClick={() => {
void props.state.toggleIncidentTimeline(
rowKey(),
props.alert.id,
props.alert.startTime,
);
}}
>
{props.state.expandedIncidents().has(rowKey()) ? 'Hide' : 'Timeline'}
</button>
</Show>
<Show when={props.alert.source === 'alert' && props.alert.resourceId}>
<button
type="button"
class="rounded-md border border-border px-2 py-1 text-[10px] text-muted hover:bg-surface-hover"
title={getAlertResourceIncidentViewTitle()}
onClick={() => {
void props.state.openResourceIncidentPanel(
props.alert.resourceId as string,
props.alert.resourceName,
);
}}
>
Resource
</button>
</Show>
<Show
when={
props.alert.source === 'alert' &&
(props.alert.status === 'active' || props.alert.status === 'acknowledged')
}
>
<InvestigateAlertButton
alert={{
id: props.alert.id,
type: props.alert.rawAlertType || props.alert.title,
level: props.alert.severity as 'warning' | 'critical',
resourceId: props.alert.resourceId || '',
resourceName: props.alert.resourceName,
node: props.alert.node || '',
nodeDisplayName: props.alert.nodeDisplayName,
instance: '',
message: props.alert.description || '',
value: 0,
threshold: 0,
startTime: props.alert.startTime,
lastSeen: props.alert.startTime,
acknowledged: props.alert.status === 'acknowledged',
}}
resourceType={props.alert.resourceType}
variant="icon"
size="sm"
/>
</Show>
</div>
);
}
@@ -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 (
<div class="space-y-3 md:hidden" data-testid="alert-history-mobile-list">
<For each={props.state.groupedAlerts()}>
{(group) => (
<section class="overflow-hidden rounded-lg border border-border bg-surface">
<header class="flex items-start justify-between gap-3 border-b border-border bg-surface-alt px-3 py-2">
<h3
class="min-w-0 truncate text-xs font-semibold text-base-content"
title={group.fullLabel}
>
{group.label}
</h3>
<span class="shrink-0 text-[10px] text-muted">{getGroupSummaryLabel(group)}</span>
</header>
<div class="divide-y divide-border-subtle">
<For each={group.alerts}>
{(alert) => {
const rowKey = () => props.state.getIncidentRowKey(alert);
const status = () => getAlertHistoryStatusPresentation(alert.status);
return (
<article class={`p-3 ${status().rowClassName}`}>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h4
class="truncate text-sm font-semibold text-base-content"
title={alert.resourceName}
>
{alert.resourceName}
</h4>
<span
class={`${getAlertHistoryResourceTypeBadgeClass(alert.resourceType)} mt-1 inline-flex`}
title={alert.resourceType}
>
{alert.title}
</span>
</div>
<div class="flex shrink-0 flex-col items-end gap-1">
<span class={getAlertIncidentLevelBadgeClass(alert.severity)}>
{alert.severity}
</span>
<span class={status().className}>{status().label}</span>
</div>
</div>
<p class="mt-2 text-xs leading-relaxed text-base-content">
{alert.description}
</p>
<dl class="mt-3 grid grid-cols-3 gap-2 text-[10px] text-muted">
<div>
<dt class="uppercase tracking-wide">Time</dt>
<dd class="mt-0.5 font-mono text-base-content">
{new Date(alert.startTime).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
})}
</dd>
</div>
<div>
<dt class="uppercase tracking-wide">Duration</dt>
<dd class="mt-0.5 text-base-content">{alert.duration}</dd>
</div>
<div class="min-w-0">
<dt class="uppercase tracking-wide">Node</dt>
<dd
class="mt-0.5 truncate text-base-content"
title={alert.nodeDisplayName || alert.node || ''}
>
{alert.nodeDisplayName || alert.node || '—'}
</dd>
</div>
</dl>
<AlertHistoryItemActions alert={alert} state={props.state} class="mt-3" />
<Show
when={
alert.source === 'alert' && props.state.expandedIncidents().has(rowKey())
}
>
<div class="mt-3 border-t border-border pt-3">
<IncidentTimelinePanel
loading={props.state.incidentLoading()[rowKey()]}
error={props.state.incidentErrors()[rowKey()]}
timeline={props.state.incidentTimelines()[rowKey()]}
filters={props.state.historyIncidentEventFilters}
setFilters={props.state.setHistoryIncidentEventFilters}
filterVariant="compact"
eventCardVariant="surface"
noteDraft={props.state.incidentNoteDrafts()[rowKey()] || ''}
onNoteDraftChange={(value) =>
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,
);
}}
/>
</div>
</Show>
</article>
);
}}
</For>
</div>
</section>
)}
</For>
</div>
);
}
@@ -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<AlertHistoryState['groupedAlerts']>[number]['alerts'][number];
@@ -30,7 +29,7 @@ export function AlertHistoryTableAlertRow(props: AlertHistoryTableAlertRowProps)
class={`border-b border-border hover:bg-surface-hover ${historyStatusPresentation().rowClassName}`}
>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} font-mono whitespace-nowrap text-muted`}
class={`${getPlatformTableCellClassForKind('text')} alert-history-context-column font-mono whitespace-nowrap text-muted`}
>
{new Date(props.alert.startTime).toLocaleTimeString('en-US', {
hour: '2-digit',
@@ -45,7 +44,7 @@ export function AlertHistoryTableAlertRow(props: AlertHistoryTableAlertRowProps)
{props.alert.resourceName}
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('badge')}>
<TableCell class={`${getPlatformTableCellClassForKind('badge')} hidden md:table-cell`}>
<span
class={getAlertHistoryResourceTypeBadgeClass(props.alert.resourceType)}
title={props.alert.resourceType}
@@ -67,7 +66,9 @@ export function AlertHistoryTableAlertRow(props: AlertHistoryTableAlertRowProps)
{props.alert.description}
</TableCell>
<TableCell class={`${getPlatformTableCellClassForKind('numeric-value')} text-muted`}>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} alert-history-context-column text-muted`}
>
{props.alert.duration}
</TableCell>
@@ -78,73 +79,14 @@ export function AlertHistoryTableAlertRow(props: AlertHistoryTableAlertRowProps)
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} truncate text-muted`}
class={`${getPlatformTableCellClassForKind('text')} hidden truncate text-muted md:table-cell`}
title={props.alert.nodeDisplayName || props.alert.node || ''}
>
{props.alert.nodeDisplayName || props.alert.node || '—'}
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('badge')}>
<div class="flex items-center justify-center gap-1">
<Show when={props.alert.source === 'alert'}>
<button
type="button"
class="rounded-md border border-border px-2 py-1 text-[10px] text-muted hover:bg-surface-hover"
onClick={() => {
void props.state.toggleIncidentTimeline(
rowKey(),
props.alert.id,
props.alert.startTime,
);
}}
>
{props.state.expandedIncidents().has(rowKey()) ? 'Hide' : 'Timeline'}
</button>
</Show>
<Show when={props.alert.source === 'alert' && props.alert.resourceId}>
<button
type="button"
class="rounded-md border border-border px-2 py-1 text-[10px] text-muted hover:bg-surface-hover"
title={getAlertResourceIncidentViewTitle()}
onClick={() => {
void props.state.openResourceIncidentPanel(
props.alert.resourceId as string,
props.alert.resourceName,
);
}}
>
Resource
</button>
</Show>
<Show
when={
props.alert.source === 'alert' &&
(props.alert.status === 'active' || props.alert.status === 'acknowledged')
}
>
<InvestigateAlertButton
alert={{
id: props.alert.id,
type: props.alert.rawAlertType || props.alert.title,
level: props.alert.severity as 'warning' | 'critical',
resourceId: props.alert.resourceId || '',
resourceName: props.alert.resourceName,
node: props.alert.node || '',
nodeDisplayName: props.alert.nodeDisplayName,
instance: '',
message: props.alert.description || '',
value: 0,
threshold: 0,
startTime: props.alert.startTime,
lastSeen: props.alert.startTime,
acknowledged: props.alert.status === 'acknowledged',
}}
resourceType={props.alert.resourceType}
variant="icon"
size="sm"
/>
</Show>
</div>
<AlertHistoryItemActions alert={props.alert} state={props.state} class="justify-center" />
</TableCell>
</TableRow>
@@ -13,7 +13,7 @@ interface AlertHistoryTableGroupRowProps {
group: AlertHistoryGroup;
}
function getGroupSummaryLabel(group: AlertHistoryGroup) {
export function getGroupSummaryLabel(group: AlertHistoryGroup) {
const alertCount = group.alerts.filter((alert) => alert.source === 'alert').length;
const aiCount = group.alerts.filter((alert) => alert.source === 'ai').length;
const parts = [];
@@ -9,6 +9,7 @@ import {
} from '@/utils/alertOverviewPresentation';
import { getTypeColumnLabel } from '@/utils/typeColumnPresentation';
import { AlertHistoryMobileList } from './AlertHistoryMobileList';
import { AlertHistoryTableAlertRow } from './AlertHistoryTableAlertRow';
import { AlertHistoryTableGroupRow } from './AlertHistoryTableGroupRow';
import type { AlertHistoryState } from './useAlertHistoryState';
@@ -31,72 +32,77 @@ export function AlertHistoryTableSection(props: AlertHistoryTableSectionProps) {
</div>
}
>
<TableCard class="mb-2">
<Table class="w-full min-w-[800px] text-[11px] sm:text-sm">
<TableHeader>
<TableRow class="border-b border-border bg-surface-hover text-muted">
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} text-[10px] font-medium uppercase tracking-wider sm:text-xs`}
>
Timestamp
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('name')} text-[10px] font-medium uppercase tracking-wider sm:text-xs`}
>
Resource
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('badge')} text-[10px] font-medium uppercase tracking-wider sm:text-xs`}
>
{getTypeColumnLabel()}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('badge')} text-[10px] font-medium uppercase tracking-wider sm:text-xs`}
>
Severity
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} text-[10px] font-medium uppercase tracking-wider sm:text-xs`}
>
Message
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} text-[10px] font-medium uppercase tracking-wider sm:text-xs`}
>
Duration
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('badge')} text-[10px] font-medium uppercase tracking-wider sm:text-xs`}
>
Status
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} text-[10px] font-medium uppercase tracking-wider sm:text-xs`}
>
Node
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('badge')} text-[10px] font-medium uppercase tracking-wider sm:text-xs`}
>
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<For each={props.state.groupedAlerts()}>
{(group) => (
<>
<AlertHistoryTableGroupRow group={group} />
<>
<AlertHistoryMobileList state={props.state} />
<TableCard class="mb-2 hidden md:block">
<Table class="table-fixed w-full min-w-0 text-[11px] sm:text-sm">
<TableHeader>
<TableRow class="border-b border-border bg-surface-hover text-muted">
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} alert-history-context-column text-[10px] font-medium uppercase tracking-wider sm:text-xs`}
>
Timestamp
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('name')} text-[10px] font-medium uppercase tracking-wider sm:text-xs`}
>
Resource
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('badge')} hidden text-[10px] font-medium uppercase tracking-wider sm:text-xs md:table-cell`}
>
{getTypeColumnLabel()}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('badge')} text-[10px] font-medium uppercase tracking-wider sm:text-xs`}
>
Severity
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} text-[10px] font-medium uppercase tracking-wider sm:text-xs`}
>
Message
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} alert-history-context-column text-[10px] font-medium uppercase tracking-wider sm:text-xs`}
>
Duration
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('badge')} text-[10px] font-medium uppercase tracking-wider sm:text-xs`}
>
Status
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden text-[10px] font-medium uppercase tracking-wider sm:text-xs md:table-cell`}
>
Node
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('badge')} text-[10px] font-medium uppercase tracking-wider sm:text-xs`}
>
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<For each={props.state.groupedAlerts()}>
{(group) => (
<>
<AlertHistoryTableGroupRow group={group} />
<For each={group.alerts}>
{(alert) => <AlertHistoryTableAlertRow alert={alert} state={props.state} />}
</For>
</>
)}
</For>
</TableBody>
</Table>
</TableCard>
<For each={group.alerts}>
{(alert) => (
<AlertHistoryTableAlertRow alert={alert} state={props.state} />
)}
</For>
</>
)}
</For>
</TableBody>
</Table>
</TableCard>
</>
</Show>
}
>
@@ -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<string>(),
incidentLoading: () => ({}),
incidentErrors: () => ({}),
incidentTimelines: () => ({}),
historyIncidentEventFilters: () => [],
setHistoryIncidentEventFilters: vi.fn(),
incidentNoteDrafts: () => ({}),
setIncidentNoteDraft: vi.fn(),
incidentNoteSaving: () => new Set<string>(),
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(() => <AlertHistoryMobileList state={state} />);
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');
});
});
+63
View File
@@ -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;
}
@@ -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',
);
@@ -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',