Split thresholds family data owners

This commit is contained in:
rcourtman
2026-03-22 17:49:44 +00:00
parent ac4b0806ee
commit 6408ab9dec
11 changed files with 1213 additions and 1125 deletions
@@ -130,8 +130,17 @@ Alert threshold tables now route their visible resource row labels, search
labels, and persisted override display names through the same shared helper
so governed agent, guest, and storage rows do not leak raw names when the
threshold editor saves or re-renders them.
That threshold editor data shaping now lives under
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts`,
That threshold editor data shaping now routes through
`frontend-modern/src/features/alerts/thresholds/thresholdsResourceModel.ts`
for shared override-ID compatibility, grouped resource normalization, and
storage status policy, while
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts`
stays the composition owner for the family-specific threshold projectors in
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsHostData.ts`,
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsDockerData.ts`,
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsGuestData.ts`,
and
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsInfrastructureData.ts`.
backup and snapshot default sanitization plus factory-drift policy now live in
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsRecoveryDefaultsState.ts`,
while `frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts`
@@ -143,8 +152,8 @@ effects now route through
`frontend-modern/src/components/Alerts/ThresholdsTable.tsx` is now limited to
table interaction and presentation. New threshold row grouping,
override-ID compatibility, resource normalization, or thresholds-table
controller logic should land in those hooks rather than being rebuilt inside
the table component.
controller logic should land in those threshold model and family hooks rather
than being rebuilt inside the table component.
The alert resource thresholds editor now follows the same shape: shared metric
normalization, bounds, value-resolution, and override-label logic live in
`frontend-modern/src/components/Alerts/alertResourceTableModel.ts`, render-heavy
@@ -236,12 +236,15 @@ inline.
The thresholds editor now follows that same split more tightly:
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts`
must stay the table-shell owner for route sync and local UI state, while
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts`
stays the composition shell for threshold resource-family projectors,
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsRecoveryDefaultsState.ts`
owns backup/snapshot default sanitization and factory-drift policy, and
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts`
owns override save/bulk/toggle persistence and alert-removal side effects. The
table-shell hook should not re-accumulate raw override mutation logic or
recovery-threshold defaults policy inline.
table-shell hook should not re-accumulate raw override mutation logic,
recovery-threshold defaults policy, or resource-family projection engines
inline.
The updates settings surface now follows the same presentation-owner rule.
`frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx` stays the
@@ -430,10 +433,15 @@ thresholds-table adapter logic should stay feature-owned unless it graduates
into a shared primitive used by more than one alert surface.
Within that thresholds surface, `frontend-modern/src/components/Alerts/ThresholdsTable.tsx`
is now explicitly a feature consumer rather than the data or controller owner.
Canonical threshold row shaping, override-ID compatibility, grouped resource
normalization, and thresholds-table controller state live in
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts`,
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsRecoveryDefaultsState.ts`,
Canonical threshold row shaping now routes through
`frontend-modern/src/features/alerts/thresholds/thresholdsResourceModel.ts`
plus the family-owned feature hooks
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsHostData.ts`,
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsDockerData.ts`,
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsGuestData.ts`,
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsInfrastructureData.ts`,
with `frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts`
limited to composing them. Thresholds-table controller state lives in
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts`,
so future cleanup should extend those feature hooks instead of rebuilding
resource normalization or thresholds-table runtime state inside the table
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource';
import {
agentDiskResourceId,
hostOverrideIdCandidates,
normalizeStorageStatus,
uniqueIds,
} from '../thresholdsResourceModel';
describe('thresholdsResourceModel', () => {
it('deduplicates candidate identifiers while preserving order', () => {
expect(uniqueIds(' agent-1 ', undefined, 'agent-1', 'agent-2', '')).toEqual([
'agent-1',
'agent-2',
]);
});
it('builds host override candidates from the canonical resource identifiers', () => {
const resource = {
id: 'agent-runtime',
type: 'agent',
discoveryTarget: {
resourceType: 'agent',
resourceId: 'agent-discovery',
agentId: 'agent-discovery',
},
agent: {
agentId: 'agent-runtime',
},
platformData: {
agent: {
agentId: 'agent-platform',
},
agentId: 'agent-platform',
},
} as unknown as Resource;
expect(hostOverrideIdCandidates(resource)).toEqual([
'agent-discovery',
'agent-runtime',
'agent-platform',
]);
});
it('sanitizes agent disk ids with the backend-compatible label rules', () => {
expect(agentDiskResourceId('agent-1', '/var/lib/docker', '')).toBe(
'agent:agent-1/disk:var-lib-docker',
);
expect(agentDiskResourceId('agent-1', '', '/dev/sda1')).toBe('agent:agent-1/disk:dev-sda1');
});
it('normalizes storage status to the table availability contract', () => {
expect(normalizeStorageStatus('online')).toBe('available');
expect(normalizeStorageStatus('running')).toBe('available');
expect(normalizeStorageStatus('offline')).toBe('offline');
expect(normalizeStorageStatus(undefined)).toBe('offline');
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,264 @@
import { createMemo } from 'solid-js';
import { getPreferredResourceHostname } from '@/utils/resourceIdentity';
import type { Resource } from '@/types/resource';
import { getAlertResourceDisplayLabel } from '@/features/alerts/helpers';
import type { GroupHeaderMeta, Resource as TableResource } from '../tableTypes';
import { ThresholdsDataInputs } from '../thresholdsResourceModel';
import {
createOverridesMap,
dockerContainerOverrideIdCandidates,
dockerHostOverrideIdCandidates,
findOverrideByCandidates,
getFriendlyAlertNodeName,
getFriendlyNodeName,
hasThresholdDiff,
platformData,
} from '../thresholdsResourceModel';
export function useThresholdsDockerData(inputs: ThresholdsDataInputs) {
const { props, editingId, searchTerm } = inputs;
const dockerHostsWithOverrides = createMemo<TableResource[]>((prev = []) => {
if (editingId()) {
return prev;
}
const search = searchTerm().toLowerCase();
const overridesMap = createOverridesMap(props.overrides());
const seen = new Set<string>();
const hosts: TableResource[] = (props.dockerHosts ?? []).map((host) => {
const idCandidates = dockerHostOverrideIdCandidates(host);
const originalName = getAlertResourceDisplayLabel(host);
const friendlyName = getFriendlyAlertNodeName(originalName, host.policy);
const override = findOverrideByCandidates(overridesMap, idCandidates);
const resourceId = override?.id || idCandidates[0] || host.id;
const disableConnectivity = override?.disableConnectivity || false;
seen.add(resourceId);
return {
id: resourceId,
name: friendlyName,
displayName: friendlyName,
rawName: originalName,
type: 'dockerHost' as const,
resourceType: 'Container Runtime',
node: getPreferredResourceHostname(host),
instance: (platformData(host)?.platform as string) || (platformData(host)?.osName as string) || '',
status: host.status,
hasOverride: disableConnectivity,
disableConnectivity,
thresholds: override?.thresholds || {},
defaults: {},
editable: false,
} satisfies TableResource;
});
(props.overrides() ?? [])
.filter((override) => override.type === 'dockerHost' && !seen.has(override.id))
.forEach((override) => {
const originalName = override.name || override.id;
const friendlyName = getFriendlyNodeName(originalName);
hosts.push({
id: override.id,
name: friendlyName,
displayName: friendlyName,
rawName: originalName,
type: 'dockerHost',
resourceType: 'Container Runtime',
node: override.node || '',
instance: override.instance || '',
status: 'unknown',
hasOverride: true,
disableConnectivity: override.disableConnectivity || false,
thresholds: override.thresholds || {},
defaults: {},
editable: false,
});
});
return search ? hosts.filter((host) => host.name.toLowerCase().includes(search)) : hosts;
}, []);
const dockerContainersByHostId = createMemo(() => {
const map = new Map<string, Resource[]>();
(props.allResources ?? []).forEach((resource) => {
if (resource.type !== 'app-container') return;
const parentId = resource.parentId;
if (!parentId) return;
const existing = map.get(parentId);
if (existing) {
existing.push(resource);
} else {
map.set(parentId, [resource]);
}
});
return map;
});
const dockerContainersGroupedByHost = createMemo<Record<string, TableResource[]>>((prev = {}) => {
if (editingId()) {
return prev;
}
const search = searchTerm().toLowerCase();
const overridesMap = createOverridesMap(props.overrides());
const groups: Record<string, TableResource[]> = {};
const seen = new Set<string>();
(props.dockerHosts ?? []).forEach((host) => {
const dockerHostIds = dockerHostOverrideIdCandidates(host);
const dockerHostIdForActions = dockerHostIds[0] || host.id;
const hostLabel = getAlertResourceDisplayLabel(host);
const friendlyHostName = getFriendlyAlertNodeName(hostLabel, host.policy);
const hostLabelLower = hostLabel.toLowerCase();
const friendlyHostNameLower = friendlyHostName.toLowerCase();
const hostHostname = getPreferredResourceHostname(host);
const containers = dockerContainersByHostId().get(host.id) ?? [];
containers.forEach((container) => {
const shortId = container.id.includes('/')
? (container.id.split('/').pop() ?? container.id)
: container.id;
const resourceIdCandidates = dockerContainerOverrideIdCandidates(host, shortId);
const override = findOverrideByCandidates(overridesMap, resourceIdCandidates);
const resourceId =
override?.id || resourceIdCandidates[0] || `docker:${dockerHostIdForActions}/${shortId}`;
const overrideSeverity = override?.poweredOffSeverity;
const hasCustomThresholds = hasThresholdDiff(
override,
props.dockerDefaults as Record<string, number | undefined>,
);
const hasOverride =
hasCustomThresholds ||
Boolean(override?.disabled) ||
Boolean(override?.disableConnectivity) ||
overrideSeverity !== undefined;
const containerName = getAlertResourceDisplayLabel(container, shortId);
const image = (platformData(container)?.image as string) ?? '';
const matchesSearch =
!search ||
containerName.toLowerCase().includes(search) ||
hostLabelLower.includes(search) ||
friendlyHostNameLower.includes(search) ||
image.toLowerCase().includes(search);
if (!matchesSearch) return;
const groupKey = friendlyHostName || hostLabel;
const resource: TableResource = {
id: resourceId,
name: containerName,
type: 'dockerContainer',
resourceType: 'Container',
node: groupKey,
instance: hostHostname,
status: container.status,
hasOverride,
disabled: override?.disabled || false,
disableConnectivity: override?.disableConnectivity || false,
thresholds: override?.thresholds || {},
defaults: props.dockerDefaults,
hostId: dockerHostIdForActions,
image,
poweredOffSeverity: overrideSeverity,
};
if (!groups[groupKey]) {
groups[groupKey] = [];
}
groups[groupKey].push(resource);
seen.add(resourceId);
});
});
(props.overrides() ?? [])
.filter((override) => override.type === 'dockerContainer' && !seen.has(override.id))
.forEach((override) => {
const fallbackName = override.name || override.id.split('/').pop() || override.id;
const group = 'Unassigned Containers';
if (!groups[group]) {
groups[group] = [];
}
groups[group].push({
id: override.id,
name: fallbackName,
type: 'dockerContainer',
resourceType: 'Container',
status: 'unknown',
hasOverride: true,
disabled: override.disabled || false,
disableConnectivity: override.disableConnectivity || false,
thresholds: override.thresholds || {},
defaults: props.dockerDefaults,
poweredOffSeverity: override.poweredOffSeverity,
});
});
Object.keys(groups).forEach((group) => {
groups[group].sort((a, b) => a.name.localeCompare(b.name));
});
if (!search) {
return groups;
}
const filteredGroups: Record<string, TableResource[]> = {};
Object.entries(groups).forEach(([group, resources]) => {
if (resources.length > 0) {
filteredGroups[group] = resources;
}
});
return filteredGroups;
}, {});
const dockerContainersFlat = createMemo<TableResource[]>(() =>
Object.values(dockerContainersGroupedByHost() ?? {}).flat(),
);
const totalDockerContainers = createMemo(() =>
(props.dockerHosts ?? []).reduce(
(sum, host) => sum + (dockerContainersByHostId().get(host.id)?.length ?? 0),
0,
),
);
const dockerHostGroupMeta = createMemo<Record<string, GroupHeaderMeta>>(() => {
const meta: Record<string, GroupHeaderMeta> = {};
(props.dockerHosts ?? []).forEach((host) => {
const originalName = getAlertResourceDisplayLabel(host);
const friendlyName = getFriendlyAlertNodeName(originalName, host.policy);
const headerMeta: GroupHeaderMeta = {
displayName: friendlyName,
rawName: originalName,
status: host.status,
};
const hostname = getPreferredResourceHostname(host);
[friendlyName, originalName, hostname, host.id]
.filter((key: string | undefined): key is string => Boolean(key && key.trim()))
.forEach((key: string) => {
meta[key.trim()] = headerMeta;
});
});
meta['Unassigned Containers'] = {
displayName: 'Unassigned Containers',
status: 'unknown',
};
return meta;
});
return {
dockerHostsWithOverrides,
dockerContainersByHostId,
dockerContainersGroupedByHost,
dockerContainersFlat,
totalDockerContainers,
dockerHostGroupMeta,
};
}
@@ -0,0 +1,112 @@
import { createMemo } from 'solid-js';
import { getAlertResourceDisplayLabel } from '@/features/alerts/helpers';
import type { GroupHeaderMeta, Resource as TableResource } from '../tableTypes';
import { ThresholdsDataInputs } from '../thresholdsResourceModel';
import {
buildNodeHeaderMeta,
createOverridesMap,
hasThresholdDiff,
platformData,
} from '../thresholdsResourceModel';
export function useThresholdsGuestData(inputs: ThresholdsDataInputs) {
const { props, editingId, searchTerm } = inputs;
const guestsGroupedByNode = createMemo<Record<string, TableResource[]>>((prev = {}) => {
if (editingId()) {
return prev;
}
const search = searchTerm().toLowerCase();
const overridesMap = createOverridesMap(props.overrides());
const guests = (props.allGuests() ?? []).map((guest) => {
const data = platformData(guest);
const vmid = (data?.vmid as number | undefined) ?? undefined;
const node = (data?.node as string | undefined) ?? '';
const instance = (data?.instance as string | undefined) ?? guest.platformId ?? '';
const override = overridesMap.get(guest.id);
const overrideSeverity = override?.poweredOffSeverity;
const hasCustomThresholds = hasThresholdDiff(
override,
props.guestDefaults as Record<string, number | undefined>,
);
const hasOverride =
hasCustomThresholds ||
Boolean(override?.disabled) ||
Boolean(override?.disableConnectivity) ||
overrideSeverity !== undefined;
return {
id: guest.id,
name: getAlertResourceDisplayLabel(guest),
displayName: getAlertResourceDisplayLabel(guest),
rawName: guest.name,
type: 'guest' as const,
resourceType: guest.type === 'vm' ? 'VM' : 'Container',
vmid,
node,
instance,
status: guest.status,
hasOverride,
disabled: override?.disabled || false,
disableConnectivity: override?.disableConnectivity || false,
thresholds: override?.thresholds || {},
defaults: props.guestDefaults,
backup: override?.backup || props.backupDefaults(),
snapshot: override?.snapshot || props.snapshotDefaults(),
poweredOffSeverity: overrideSeverity,
};
});
const filteredGuests = search
? guests.filter(
(guest) =>
guest.name.toLowerCase().includes(search) ||
guest.vmid?.toString().includes(search) ||
guest.node?.toLowerCase().includes(search),
)
: guests;
const grouped: Record<string, TableResource[]> = {};
filteredGuests.forEach((guest) => {
const groupKey = guest.instance || guest.node || 'Unknown';
if (!grouped[groupKey]) {
grouped[groupKey] = [];
}
grouped[groupKey].push(guest);
});
Object.keys(grouped).forEach((node) => {
grouped[node].sort((a, b) => {
if (a.vmid && b.vmid) return a.vmid - b.vmid;
return a.name.localeCompare(b.name);
});
});
return grouped;
}, {});
const guestsFlat = createMemo<TableResource[]>(() =>
Object.values(guestsGroupedByNode() ?? {}).flat(),
);
const guestGroupHeaderMeta = createMemo<Record<string, GroupHeaderMeta>>(() => {
const meta: Record<string, GroupHeaderMeta> = {};
(props.nodes ?? []).forEach((node) => {
const { headerMeta, keys } = buildNodeHeaderMeta(node);
keys.forEach((key: string) => {
meta[key] = headerMeta;
});
});
return meta;
});
return {
guestsGroupedByNode,
guestsFlat,
guestGroupHeaderMeta,
};
}
@@ -0,0 +1,277 @@
import { createMemo } from 'solid-js';
import { getAlertResourceDisplayLabel } from '@/features/alerts/helpers';
import type { Resource as TableResource } from '../tableTypes';
import { ThresholdsDataInputs } from '../thresholdsResourceModel';
import {
agentDiskResourceId,
createOverridesMap,
findOverrideByCandidates,
getFriendlyAlertNodeName,
hasThresholdDiff,
hostActionId,
hostOverrideIdCandidates,
platformData,
readRecord,
readString,
} from '../thresholdsResourceModel';
export function useThresholdsHostData(inputs: ThresholdsDataInputs) {
const { props, editingId, searchTerm } = inputs;
const nodesWithOverrides = createMemo<TableResource[]>((prev = []) => {
if (editingId()) {
return prev;
}
const search = searchTerm().toLowerCase();
const overridesMap = createOverridesMap(props.overrides());
const nodes = (props.nodes ?? []).map((node) => {
const override = overridesMap.get(node.id);
const data = platformData(node);
const clusterName = (data?.clusterName as string | undefined) ?? undefined;
const isClusterMember =
(data?.isClusterMember as boolean | undefined) ?? Boolean(node.clusterId);
const hasCustomThresholds = hasThresholdDiff(
override,
props.nodeDefaults as Record<string, number | undefined>,
);
const note = typeof override?.note === 'string' ? override.note : undefined;
const hasNote = Boolean(note && note.trim().length > 0);
const originalDisplayName = getAlertResourceDisplayLabel(node);
const friendlyName = getFriendlyAlertNodeName(originalDisplayName, node.policy, clusterName);
const rawName = node.name;
const sanitizedName = friendlyName || originalDisplayName || rawName.split('.')[0] || rawName;
const guestUrlValue = typeof data?.guestURL === 'string' ? data.guestURL.trim() : '';
const hostValue = (typeof data?.host === 'string' ? data.host.trim() : '') || rawName;
const normalizedHost =
guestUrlValue && guestUrlValue !== ''
? guestUrlValue.startsWith('http')
? guestUrlValue
: `https://${guestUrlValue}`
: hostValue.startsWith('http://') || hostValue.startsWith('https://')
? hostValue
: `https://${hostValue.includes(':') ? hostValue : `${hostValue}:8006`}`;
return {
id: node.id,
name: sanitizedName,
displayName: sanitizedName,
rawName: originalDisplayName,
host: normalizedHost,
type: 'agent' as const,
resourceType: 'Agent',
status: node.status,
uptime: node.uptime,
cpu: (node.cpu?.current ?? 0) / 100,
memory: node.memory?.current,
hasOverride: hasCustomThresholds || hasNote || Boolean(override?.disableConnectivity),
disabled: false,
disableConnectivity: override?.disableConnectivity || false,
thresholds: override?.thresholds || {},
defaults: props.nodeDefaults,
clusterName: isClusterMember ? clusterName?.trim() : undefined,
isClusterMember,
instance: node.platformId,
note,
} satisfies TableResource;
});
return search ? nodes.filter((node) => node.name.toLowerCase().includes(search)) : nodes;
}, []);
const agentsWithOverrides = createMemo<TableResource[]>((prev = []) => {
if (editingId()) {
return prev;
}
const search = searchTerm().toLowerCase();
const overridesMap = createOverridesMap(props.overrides());
const seen = new Set<string>();
const agents: TableResource[] = (props.agents ?? []).map((agentResource) => {
const idCandidates = hostOverrideIdCandidates(agentResource);
const override = findOverrideByCandidates(overridesMap, idCandidates);
const resourceId = override?.id || idCandidates[0] || agentResource.id;
const hasCustomThresholds = hasThresholdDiff(
override,
props.agentDefaults as Record<string, number | undefined>,
);
const displayName = getAlertResourceDisplayLabel(agentResource);
const data = platformData(agentResource);
const agentData = readRecord(data?.agent);
seen.add(resourceId);
return {
id: resourceId,
name: displayName,
displayName,
rawName: agentResource.identity?.hostname ?? agentResource.name,
type: 'agent' as const,
resourceType: 'Agent',
node: displayName,
instance:
readString(agentData?.platform) ||
readString(agentData?.osName) ||
readString(data?.platform) ||
readString(data?.osName) ||
'',
status: agentResource.status,
hasOverride:
hasCustomThresholds || Boolean(override?.disabled) || Boolean(override?.disableConnectivity),
disabled: override?.disabled || false,
disableConnectivity: override?.disableConnectivity || false,
thresholds: override?.thresholds || {},
defaults: props.agentDefaults,
} satisfies TableResource;
});
(props.overrides() ?? [])
.filter((override) => override.type === 'agent' && !seen.has(override.id))
.forEach((override) => {
const name = override.name?.trim() || override.id;
agents.push({
id: override.id,
name,
displayName: name,
rawName: name,
type: 'agent' as const,
resourceType: 'Agent',
node: '',
instance: '',
status: 'unknown',
hasOverride: true,
disabled: override.disabled || false,
disableConnectivity: override.disableConnectivity || false,
thresholds: override.thresholds || {},
defaults: props.agentDefaults,
} satisfies TableResource);
});
return search ? agents.filter((agent) => agent.name.toLowerCase().includes(search)) : agents;
}, []);
const agentDisksWithOverrides = createMemo<TableResource[]>((prev = []) => {
if (editingId()) {
return prev;
}
const search = searchTerm().toLowerCase();
const overridesMap = createOverridesMap(props.overrides());
const seen = new Set<string>();
const disks: TableResource[] = [];
(props.agents ?? []).forEach((agentResource) => {
const agentDisplayName = getAlertResourceDisplayLabel(agentResource);
const agentIdCandidates = hostOverrideIdCandidates(agentResource);
const agentIdForActions = hostActionId(agentResource);
const data = platformData(agentResource);
const platformAgent = readRecord(data?.agent);
const disksFromPlatformRoot = Array.isArray(data?.disks) ? data.disks : null;
const disksFromPlatformAgent = Array.isArray(platformAgent?.disks) ? platformAgent.disks : null;
const disksFromResourceAgent = Array.isArray(agentResource.agent?.disks)
? agentResource.agent.disks
: null;
const disksForAgent = (disksFromPlatformRoot ||
disksFromPlatformAgent ||
disksFromResourceAgent ||
[]) as Array<{
mountpoint?: string;
device?: string;
used?: number;
total?: number;
type?: string;
}>;
disksForAgent.forEach((disk) => {
const diskLabel = disk.mountpoint?.trim() || disk.device?.trim() || 'disk';
const resourceIdCandidates = agentIdCandidates.map((agentId) =>
agentDiskResourceId(agentId, disk.mountpoint || '', disk.device),
);
const override = findOverrideByCandidates(overridesMap, resourceIdCandidates);
const resourceId = override?.id || resourceIdCandidates[0];
if (!resourceId) return;
const hasCustomThresholds = hasThresholdDiff(override, {
disk: props.agentDefaults.disk,
});
seen.add(resourceId);
disks.push({
id: resourceId,
name: diskLabel,
displayName: diskLabel,
rawName: disk.device || diskLabel,
type: 'agentDisk' as const,
resourceType: 'Agent Disk',
host: agentIdForActions,
node: agentDisplayName,
instance: disk.type || '',
status: agentResource.status,
hasOverride: hasCustomThresholds || Boolean(override?.disabled),
disabled: override?.disabled || false,
thresholds: override?.thresholds || {},
defaults: { disk: props.agentDefaults.disk },
subtitle: `${((disk.used || 0) / 1024 / 1024 / 1024).toFixed(1)} / ${((disk.total || 0) / 1024 / 1024 / 1024).toFixed(1)} GB`,
} satisfies TableResource);
});
});
(props.overrides() ?? [])
.filter((override) => override.type === 'agentDisk' && !seen.has(override.id))
.forEach((override) => {
const name = override.name || override.id;
disks.push({
id: override.id,
name,
displayName: name,
rawName: name,
type: 'agentDisk' as const,
resourceType: 'Agent Disk',
host: '',
node: 'Unknown Agent',
instance: '',
status: 'unknown',
hasOverride: true,
disabled: override.disabled || false,
thresholds: override.thresholds || {},
defaults: { disk: props.agentDefaults.disk },
});
});
return search
? disks.filter(
(disk) => disk.name.toLowerCase().includes(search) || disk.node?.toLowerCase().includes(search),
)
: disks;
}, []);
const agentDisksGroupedByAgent = createMemo<Record<string, TableResource[]>>(() => {
const grouped: Record<string, TableResource[]> = {};
agentDisksWithOverrides().forEach((disk) => {
const key = disk.node?.trim() || 'Unknown Agent';
if (!grouped[key]) {
grouped[key] = [];
}
grouped[key].push(disk);
});
Object.values(grouped).forEach((resources) => {
resources.sort((a, b) => a.name.localeCompare(b.name));
});
return grouped;
});
return {
nodesWithOverrides,
agentsWithOverrides,
agentDisksWithOverrides,
agentDisksGroupedByAgent,
};
}
@@ -0,0 +1,186 @@
import { createMemo } from 'solid-js';
import type { PMGThresholdDefaults } from '@/types/alerts';
import { getAlertResourceDisplayLabel } from '@/features/alerts/helpers';
import { PMG_KEY_TO_NORMALIZED, PMG_NORMALIZED_TO_KEY, PMG_THRESHOLD_COLUMNS } from '../constants';
import type { Resource as TableResource } from '../tableTypes';
import { ThresholdsDataInputs } from '../thresholdsResourceModel';
import {
createOverridesMap,
hasThresholdDiff,
normalizeStorageStatus,
storageCoords,
} from '../thresholdsResourceModel';
export function useThresholdsInfrastructureData(inputs: ThresholdsDataInputs) {
const { props, editingId, searchTerm } = inputs;
const pbsServersWithOverrides = createMemo<TableResource[]>((prev = []) => {
if (editingId()) {
return prev;
}
const search = searchTerm().toLowerCase();
const overridesMap = createOverridesMap(props.overrides());
const pbsServers = (props.pbsInstances || []).map((pbs) => {
const override = overridesMap.get(pbs.id);
const hasCustomThresholds = hasThresholdDiff(override, {
cpu: props.pbsDefaults?.cpu ?? 80,
memory: props.pbsDefaults?.memory ?? 85,
});
const disableConnectivity = override?.disableConnectivity || false;
return {
id: pbs.id,
name: pbs.name,
type: 'pbs' as const,
resourceType: 'PBS',
host: pbs.host,
status: pbs.status,
cpu: pbs.cpu,
memory: pbs.memory,
memoryUsed: pbs.memoryUsed,
memoryTotal: pbs.memoryTotal,
uptime: pbs.uptime,
hasOverride: hasCustomThresholds || disableConnectivity,
disabled: false,
disableConnectivity,
thresholds: override?.thresholds || {},
defaults: {
cpu: props.pbsDefaults?.cpu ?? 80,
memory: props.pbsDefaults?.memory ?? 85,
},
};
});
return search
? pbsServers.filter(
(pbs) => pbs.name.toLowerCase().includes(search) || pbs.host?.toLowerCase().includes(search),
)
: pbsServers;
}, []);
const pmgGlobalDefaults = createMemo<Record<string, number>>(() => {
const defaults = props.pmgThresholds();
const record: Record<string, number> = {};
PMG_THRESHOLD_COLUMNS.forEach(({ key, normalized }) => {
const value = defaults[key as keyof PMGThresholdDefaults];
record[normalized] = typeof value === 'number' && Number.isFinite(value) ? value : 0;
});
return record;
});
const pmgServersWithOverrides = createMemo<TableResource[]>((prev = []) => {
if (editingId()) {
return prev;
}
const search = searchTerm().toLowerCase();
const overridesMap = createOverridesMap(props.overrides());
const defaultThresholds = pmgGlobalDefaults();
const pmgServers = (props.pmgInstances || []).map((pmg) => {
const override = overridesMap.get(pmg.id);
const thresholdOverrides: Record<string, number> = {};
const overrideThresholds = (override?.thresholds ?? {}) as Record<string, unknown>;
Object.entries(overrideThresholds).forEach(([rawKey, rawValue]) => {
if (typeof rawValue !== 'number' || Number.isNaN(rawValue)) return;
const normalizedKey =
PMG_KEY_TO_NORMALIZED.get(rawKey as keyof PMGThresholdDefaults) ||
(PMG_NORMALIZED_TO_KEY.has(rawKey) ? rawKey : undefined);
if (!normalizedKey) return;
thresholdOverrides[normalizedKey] = rawValue;
});
const hasOverride =
Boolean(override?.disableConnectivity) ||
Boolean(override?.disabled) ||
Object.keys(thresholdOverrides).length > 0;
return {
id: pmg.id,
name: pmg.name,
type: 'pmg' as const,
resourceType: 'PMG',
host: pmg.host,
status: pmg.status,
hasOverride,
disabled: override?.disabled || false,
disableConnectivity: override?.disableConnectivity || false,
thresholds: thresholdOverrides,
defaults: { ...defaultThresholds },
};
});
return search
? pmgServers.filter(
(pmg) => pmg.name.toLowerCase().includes(search) || pmg.host?.toLowerCase().includes(search),
)
: pmgServers;
}, []);
const storageWithOverrides = createMemo<TableResource[]>((prev = []) => {
if (editingId()) {
return prev;
}
const search = searchTerm().toLowerCase();
const overridesMap = createOverridesMap(props.overrides());
const storageDevices = (props.storage ?? []).map((storage) => {
const override = overridesMap.get(storage.id);
const coords = storageCoords(storage);
const hasCustomThresholds = hasThresholdDiff(override, { usage: props.storageDefault() });
return {
id: storage.id,
name: getAlertResourceDisplayLabel(storage),
displayName: getAlertResourceDisplayLabel(storage),
rawName: storage.name,
type: 'storage' as const,
resourceType: 'Storage',
node: coords.node,
instance: coords.instance,
status: normalizeStorageStatus(storage.status),
hasOverride: hasCustomThresholds || Boolean(override?.disabled),
disabled: override?.disabled || false,
thresholds: override?.thresholds || {},
defaults: { usage: props.storageDefault() },
};
});
return search
? storageDevices.filter(
(storage) =>
storage.name.toLowerCase().includes(search) || storage.node?.toLowerCase().includes(search),
)
: storageDevices;
}, []);
const storageGroupedByNode = createMemo<Record<string, TableResource[]>>(() => {
const grouped: Record<string, TableResource[]> = {};
storageWithOverrides().forEach((storage) => {
const key = storage.node?.trim() || 'Unassigned';
if (!grouped[key]) {
grouped[key] = [];
}
grouped[key].push(storage);
});
Object.values(grouped).forEach((resources) => {
resources.sort((a, b) => a.name.localeCompare(b.name));
});
return grouped;
});
return {
pbsServersWithOverrides,
pmgGlobalDefaults,
pmgServersWithOverrides,
storageWithOverrides,
storageGroupedByNode,
};
}
@@ -0,0 +1,231 @@
import { unwrap } from 'solid-js/store';
import { requiresGovernedResourceDisplay } from '@/types/resource';
import type { Resource } from '@/types/resource';
import {
getAgentDiscoveryResourceId,
isAppContainerDiscoveryResourceType,
} from '@/utils/discoveryTarget';
import { getAlertResourceDisplayLabel } from '@/features/alerts/helpers';
import type { GroupHeaderMeta } from './tableTypes';
import type { Override, ThresholdsTableProps } from './types';
export interface ThresholdsDataInputs {
props: ThresholdsTableProps;
editingId: () => string | null;
searchTerm: () => string;
}
export const platformData = (resource: Resource): Record<string, unknown> | undefined =>
resource.platformData ? (unwrap(resource.platformData) as Record<string, unknown>) : undefined;
export const readRecord = (value: unknown): Record<string, unknown> | undefined =>
value && typeof value === 'object' ? (value as Record<string, unknown>) : undefined;
export const readString = (value: unknown): string | undefined =>
typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
export const uniqueIds = (...values: unknown[]): string[] => {
const ids: string[] = [];
const seen = new Set<string>();
values.forEach((value) => {
const normalized = readString(value);
if (!normalized || seen.has(normalized)) return;
seen.add(normalized);
ids.push(normalized);
});
return ids;
};
export const createOverridesMap = (overrides: Override[] | undefined) =>
new Map((overrides ?? []).map((override) => [override.id, override]));
export const hasThresholdDiff = (
override: Override | undefined,
defaults: Record<string, number | undefined>,
) =>
Boolean(
override?.thresholds &&
Object.keys(override.thresholds).some((key) => {
const thresholdKey = key as keyof Override['thresholds'];
return (
override.thresholds[thresholdKey] !== undefined &&
override.thresholds[thresholdKey] !== defaults[key]
);
}),
);
export function hostOverrideIdCandidates(resource: Resource): string[] {
const data = platformData(resource);
const agent = readRecord(data?.agent);
const discoveryTarget = resource.discoveryTarget ?? null;
return uniqueIds(
getAgentDiscoveryResourceId(discoveryTarget),
discoveryTarget?.agentId,
resource.agent?.agentId,
agent?.agentId,
data?.agentId,
resource.id,
);
}
export const hostActionId = (resource: Resource): string =>
hostOverrideIdCandidates(resource)[0] || resource.id;
export const dockerHostOverrideIdCandidates = (resource: Resource): string[] => {
const data = platformData(resource);
const docker = readRecord(data?.docker);
const discoveryTarget = resource.discoveryTarget;
return uniqueIds(
isAppContainerDiscoveryResourceType(discoveryTarget?.resourceType)
? discoveryTarget?.resourceId
: undefined,
docker?.hostSourceId,
data?.hostSourceId,
discoveryTarget?.agentId,
resource.id,
);
};
export const dockerContainerOverrideIdCandidates = (host: Resource, shortId: string): string[] =>
uniqueIds(
...dockerHostOverrideIdCandidates(host).map((hostId) => `docker:${hostId}/${shortId}`),
);
export const findOverrideByCandidates = (
overridesMap: Map<string, Override>,
candidates: string[],
): Override | undefined => {
for (const candidate of candidates) {
const override = overridesMap.get(candidate);
if (override) {
return override;
}
}
return undefined;
};
export const getFriendlyNodeName = (value: string, clusterName?: string): string => {
if (!value) return value;
const clusterLower = clusterName?.toLowerCase().trim();
const normalizeToken = (token?: string | null): string => {
if (!token) return '';
let result = token
.replace(/\(.*?\)/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (clusterLower) {
result = result
.split(' ')
.filter((part) => part.toLowerCase() !== clusterLower)
.join(' ')
.trim();
}
if (!result) return '';
const firstWord = result.split(/\s+/)[0] || result;
const withoutDomain = firstWord.includes('.') ? (firstWord.split('.')[0] ?? firstWord) : firstWord;
return withoutDomain.trim();
};
const parentheticalMatch = value.match(/\(([^)]+)\)/);
const parentheticalRaw = parentheticalMatch?.[1]?.trim();
let base = normalizeToken(value);
if (!base) {
base = value.trim();
}
const parenthetical = normalizeToken(parentheticalRaw);
if (parenthetical && parenthetical.toLowerCase() !== base.toLowerCase()) {
return parenthetical;
}
return base;
};
export const getFriendlyAlertNodeName = (
value: string,
policy?: Resource['policy'],
clusterName?: string,
): string => (requiresGovernedResourceDisplay(policy) ? value : getFriendlyNodeName(value, clusterName));
export function buildNodeHeaderMeta(node: Resource) {
const data = platformData(node);
const clusterName = (data?.clusterName as string | undefined) ?? undefined;
const isClusterMember =
(data?.isClusterMember as boolean | undefined) ?? Boolean(node.clusterId);
const originalDisplayName = getAlertResourceDisplayLabel(node);
const friendlyName = getFriendlyAlertNodeName(originalDisplayName, node.policy, clusterName);
const guestUrlValue = typeof data?.guestURL === 'string' ? data.guestURL.trim() : '';
const hostValue = typeof data?.host === 'string' ? data.host.trim() : '';
let host: string | undefined;
if (guestUrlValue && guestUrlValue !== '') {
host = guestUrlValue.startsWith('http') ? guestUrlValue : `https://${guestUrlValue}`;
} else if (hostValue && hostValue !== '') {
host = hostValue.startsWith('http')
? hostValue
: `https://${hostValue.includes(':') ? hostValue : `${hostValue}:8006`}`;
} else if (node.name) {
host = `https://${node.name.includes(':') ? node.name : `${node.name}:8006`}`;
}
const headerMeta: GroupHeaderMeta = {
type: 'node',
displayName: friendlyName,
rawName: originalDisplayName,
host,
status: node.status,
clusterName: isClusterMember ? clusterName?.trim() || 'Cluster' : undefined,
isClusterMember,
};
const keys = new Set<string>();
[node.name, originalDisplayName, friendlyName].forEach((value) => {
if (value && value.trim()) {
keys.add(value.trim());
}
});
return { headerMeta, keys };
}
export const agentDiskResourceId = (agentId: string, mountpoint: string, device?: string): string => {
let label = (mountpoint?.trim() || device?.trim() || 'disk').toLowerCase();
label = label
.replace(/[^a-z0-9]/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^-|-$/g, '');
if (!label) label = 'unknown';
return `agent:${agentId}/disk:${label}`;
};
export const storageCoords = (resource: Resource): { node: string; instance: string } => {
const data = platformData(resource);
if (resource.type === 'datastore') {
const instance =
(data?.pbsInstanceId as string | undefined) || resource.parentId || resource.platformId || 'pbs';
const node = (data?.pbsInstanceName as string | undefined) || instance;
return { node, instance };
}
return {
node: (data?.node as string | undefined) || '',
instance: (data?.instance as string | undefined) || resource.platformId || '',
};
};
export const normalizeStorageStatus = (status: string | undefined): string => {
switch ((status ?? '').toLowerCase()) {
case 'online':
case 'running':
case 'available':
return 'available';
default:
return 'offline';
}
};
@@ -20,9 +20,14 @@ import alertThresholdsTabSource from '@/features/alerts/tabs/ThresholdsTab.tsx?r
import recentAlertsPanelSource from '@/components/Alerts/RecentAlertsPanel.tsx?raw';
import thresholdsTableSource from '@/components/Alerts/ThresholdsTable.tsx?raw';
import thresholdsDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsData.ts?raw';
import thresholdsHostDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsHostData.ts?raw';
import thresholdsDockerDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsDockerData.ts?raw';
import thresholdsGuestDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsGuestData.ts?raw';
import thresholdsInfrastructureDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsInfrastructureData.ts?raw';
import thresholdsRecoveryDefaultsStateHookSource from '@/features/alerts/thresholds/hooks/useThresholdsRecoveryDefaultsState.ts?raw';
import thresholdsTableStateHookSource from '@/features/alerts/thresholds/hooks/useThresholdsTableState.ts?raw';
import thresholdsOverrideMutationsHookSource from '@/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts?raw';
import thresholdsResourceModelSource from '@/features/alerts/thresholds/thresholdsResourceModel.ts?raw';
import {
ALERT_TAB_SEGMENTS,
@@ -325,6 +330,23 @@ describe('tab path helpers', () => {
expect(thresholdsTableSource).not.toContain('const [searchTerm, setSearchTerm] = createSignal');
expect(thresholdsTableSource).not.toContain('const handleTabClick =');
expect(thresholdsDataHookSource).toContain('export function useThresholdsData');
expect(thresholdsDataHookSource).toContain('useThresholdsHostData(inputs)');
expect(thresholdsDataHookSource).toContain('useThresholdsDockerData(inputs)');
expect(thresholdsDataHookSource).toContain('useThresholdsGuestData(inputs)');
expect(thresholdsDataHookSource).toContain('useThresholdsInfrastructureData(inputs)');
expect(thresholdsDataHookSource).not.toContain('const hostOverrideIdCandidates =');
expect(thresholdsDataHookSource).not.toContain('const dockerContainersGroupedByHost = createMemo');
expect(thresholdsHostDataHookSource).toContain('export function useThresholdsHostData');
expect(thresholdsHostDataHookSource).toContain('hostOverrideIdCandidates(agentResource)');
expect(thresholdsDockerDataHookSource).toContain('export function useThresholdsDockerData');
expect(thresholdsDockerDataHookSource).toContain('dockerContainerOverrideIdCandidates');
expect(thresholdsGuestDataHookSource).toContain('export function useThresholdsGuestData');
expect(thresholdsInfrastructureDataHookSource).toContain(
'export function useThresholdsInfrastructureData',
);
expect(thresholdsResourceModelSource).toContain('export function hostOverrideIdCandidates');
expect(thresholdsResourceModelSource).toContain('export function buildNodeHeaderMeta');
expect(thresholdsResourceModelSource).toContain('export const normalizeStorageStatus');
expect(thresholdsTableStateHookSource).toContain('export function useThresholdsTableState');
expect(thresholdsTableStateHookSource).toContain('useThresholdsData(props, editingId, searchTerm)');
expect(thresholdsTableStateHookSource).toContain('useThresholdsRecoveryDefaultsState(props)');
@@ -342,9 +342,14 @@ import alertScheduleTabSource from '@/features/alerts/tabs/ScheduleTab.tsx?raw';
import alertThresholdsTabSource from '@/features/alerts/tabs/ThresholdsTab.tsx?raw';
import thresholdsTableSource from '@/components/Alerts/ThresholdsTable.tsx?raw';
import thresholdsDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsData.ts?raw';
import thresholdsHostDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsHostData.ts?raw';
import thresholdsDockerDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsDockerData.ts?raw';
import thresholdsGuestDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsGuestData.ts?raw';
import thresholdsInfrastructureDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsInfrastructureData.ts?raw';
import thresholdsRecoveryDefaultsStateHookSource from '@/features/alerts/thresholds/hooks/useThresholdsRecoveryDefaultsState.ts?raw';
import thresholdsTableStateHookSource from '@/features/alerts/thresholds/hooks/useThresholdsTableState.ts?raw';
import thresholdsOverrideMutationsHookSource from '@/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts?raw';
import thresholdsResourceModelSource from '@/features/alerts/thresholds/thresholdsResourceModel.ts?raw';
import alertIncidentPresentationSource from '@/utils/alertIncidentPresentation.ts?raw';
import alertHistoryPresentationSource from '@/utils/alertHistoryPresentation.ts?raw';
import bulkEditDialogSource from '@/components/Alerts/BulkEditDialog.tsx?raw';
@@ -2918,6 +2923,21 @@ describe('frontend resource type boundaries', () => {
expect(thresholdsTableSource).not.toContain('const [searchTerm, setSearchTerm] = createSignal');
expect(thresholdsTableSource).not.toContain('const handleTabClick =');
expect(thresholdsDataHookSource).toContain('export function useThresholdsData');
expect(thresholdsDataHookSource).toContain('useThresholdsHostData(inputs)');
expect(thresholdsDataHookSource).toContain('useThresholdsDockerData(inputs)');
expect(thresholdsDataHookSource).toContain('useThresholdsGuestData(inputs)');
expect(thresholdsDataHookSource).toContain('useThresholdsInfrastructureData(inputs)');
expect(thresholdsDataHookSource).not.toContain('const hostOverrideIdCandidates =');
expect(thresholdsDataHookSource).not.toContain('const dockerContainersGroupedByHost = createMemo');
expect(thresholdsHostDataHookSource).toContain('export function useThresholdsHostData');
expect(thresholdsDockerDataHookSource).toContain('export function useThresholdsDockerData');
expect(thresholdsGuestDataHookSource).toContain('export function useThresholdsGuestData');
expect(thresholdsInfrastructureDataHookSource).toContain(
'export function useThresholdsInfrastructureData',
);
expect(thresholdsResourceModelSource).toContain('export function hostOverrideIdCandidates');
expect(thresholdsResourceModelSource).toContain('export function buildNodeHeaderMeta');
expect(thresholdsResourceModelSource).toContain('export const normalizeStorageStatus');
expect(thresholdsTableStateHookSource).toContain('export function useThresholdsTableState');
expect(thresholdsTableStateHookSource).toContain('useCollapsedSections()');
expect(thresholdsTableStateHookSource).toContain('useThresholdsData(props, editingId, searchTerm)');