mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
fix(ui): make operator tables container-responsive
Contract-Neutral: Responsive presentation only; no subsystem behavior or public contract changes.
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user