Fix VMware connection test degradation handling

This commit is contained in:
rcourtman 2026-07-23 22:05:21 +01:00
parent 50bac2ccf1
commit 13c4b57885
17 changed files with 614 additions and 46 deletions

View file

@ -188,7 +188,17 @@ describe('VMwareAPI', () => {
username: 'administrator@vsphere.local',
password: 'secret',
}),
).resolves.toEqual({ success: true });
).resolves.toEqual({
success: true,
hosts: 0,
vms: 0,
datastores: 0,
networks: 0,
viRelease: undefined,
degraded: false,
issueCount: 0,
issues: [],
});
await expect(
VMwareAPI.testSavedConnection('conn/1', {
host: 'vcsa.lab.local',
@ -196,7 +206,17 @@ describe('VMwareAPI', () => {
password: '********',
insecureSkipVerify: true,
}),
).resolves.toEqual({ success: true });
).resolves.toEqual({
success: true,
hosts: 0,
vms: 0,
datastores: 0,
networks: 0,
viRelease: undefined,
degraded: false,
issueCount: 0,
issues: [],
});
expect(apiFetchJSON).toHaveBeenNthCalledWith(
1,
@ -260,6 +280,54 @@ describe('VMwareAPI', () => {
);
});
it('preserves degraded connection-test diagnostics', async () => {
vi.mocked(apiFetchJSON).mockResolvedValueOnce({
success: true,
hosts: 2,
vms: 14,
datastores: 3,
networks: 5,
viRelease: ' 8.0.2.0 ',
degraded: true,
issueCount: 1,
issues: [
{
stage: ' signals ',
entity_type: ' host ',
entity_id: ' host-101 ',
category: ' endpoint ',
message: ' VMware HostSystem recent events request failed with HTTP 500 ',
},
],
});
await expect(
VMwareAPI.testSavedConnection('conn-1', {
host: 'vcsa.lab.local',
username: 'administrator@vsphere.local',
password: '********',
}),
).resolves.toEqual({
success: true,
hosts: 2,
vms: 14,
datastores: 3,
networks: 5,
viRelease: '8.0.2.0',
degraded: true,
issueCount: 1,
issues: [
{
stage: 'signals',
entityType: 'host',
entityId: 'host-101',
category: 'endpoint',
message: 'VMware HostSystem recent events request failed with HTTP 500',
},
],
});
});
it('recognizes the masked-secret placeholder used for update preservation', () => {
expect(isRedactedVMwareSecret('********')).toBe(true);
expect(isRedactedVMwareSecret(' ******** ')).toBe(true);

View file

@ -86,8 +86,52 @@ export interface VMwareConnectionInput {
export interface VMwareConnectionTestResult {
success: boolean;
hosts: number;
vms: number;
datastores: number;
networks: number;
viRelease?: string;
degraded: boolean;
issueCount: number;
issues: VMwareConnectionTestIssue[];
}
export interface VMwareConnectionTestIssue {
stage?: string;
entityType?: string;
entityId?: string;
category?: string;
message?: string;
}
type RawVMwareConnectionTestIssue = Partial<VMwareConnectionTestIssue> & {
entity_type?: unknown;
entity_id?: unknown;
};
const normalizeVMwareConnectionTestResult = (
response: Partial<VMwareConnectionTestResult> & {
issues?: RawVMwareConnectionTestIssue[];
},
): VMwareConnectionTestResult => ({
success: strictBoolean(response.success),
hosts: finiteNumberOrUndefined(response.hosts) ?? 0,
vms: finiteNumberOrUndefined(response.vms) ?? 0,
datastores: finiteNumberOrUndefined(response.datastores) ?? 0,
networks: finiteNumberOrUndefined(response.networks) ?? 0,
viRelease: optionalTrimmedString(response.viRelease),
degraded: strictBoolean(response.degraded),
issueCount: finiteNumberOrUndefined(response.issueCount) ?? 0,
issues:
arrayOrUndefined<RawVMwareConnectionTestIssue>(response.issues)?.map((issue) => ({
stage: optionalTrimmedString(issue.stage),
entityType: optionalTrimmedString(issue.entityType ?? issue.entity_type),
entityId: optionalTrimmedString(issue.entityId ?? issue.entity_id),
category: optionalTrimmedString(issue.category),
message: optionalTrimmedString(issue.message),
})) ?? [],
});
const normalizeVMwareConnectionPollError = (
error: RawVMwareConnectionPollError | undefined,
): VMwareConnectionPollError | undefined => {
@ -213,9 +257,7 @@ export class VMwareAPI {
body: JSON.stringify(serializeVMwareConnectionInput(input)),
},
);
return {
success: strictBoolean(response.success),
};
return normalizeVMwareConnectionTestResult(response);
}
static async testSavedConnection(
@ -231,9 +273,7 @@ export class VMwareAPI {
: {}),
},
);
return {
success: strictBoolean(response.success),
};
return normalizeVMwareConnectionTestResult(response);
}
static async previewConnection(

View file

@ -90,6 +90,23 @@ export const VMwareCredentialSlot: Component<VMwareCredentialSlotProps> = (props
)}
</Show>
<Show when={props.state.connectionTestWarning()}>
{(warning) => (
<CalloutCard
data-testid="vmware-connection-test-warning"
tone="warning"
title={warning().title}
description={
<>
<p>{warning().message}</p>
<p class="mt-2">{warning().guidance}</p>
</>
}
icon={<ShieldAlert class="h-5 w-5" />}
/>
)}
</Show>
<div class="grid gap-4 sm:grid-cols-2">
<label class={formField}>
<span class={formLabel}>Name</span>

View file

@ -22,6 +22,7 @@ vi.mock('@/stores/notifications', () => ({
notificationStore: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
},
}));
@ -222,6 +223,59 @@ describe('useVMwareSettingsPanelState', () => {
expect(notificationStore.success).toHaveBeenCalledWith('VMware connection successful');
});
it('reports optional VMware test failures as a degraded success with diagnostics', async () => {
vi.mocked(VMwareAPI.listConnections).mockResolvedValueOnce([
{
id: 'conn-1',
name: 'lab-vcenter',
host: 'vcsa.lab.local',
port: 443,
username: 'administrator@vsphere.local',
password: '********',
insecureSkipVerify: true,
enabled: true,
},
] as never);
vi.mocked(VMwareAPI.testSavedConnection).mockResolvedValueOnce({
success: true,
hosts: 2,
vms: 14,
datastores: 3,
networks: 5,
viRelease: '8.0.2.0',
degraded: true,
issueCount: 1,
issues: [
{
stage: 'signals',
entityType: 'host',
entityId: 'host-101',
category: 'endpoint',
message: 'VMware HostSystem recent events request failed with HTTP 500',
},
],
});
const { result } = renderHook(() => useVMwareSettingsPanelState());
await waitFor(() => expect(result.connections()).toHaveLength(1));
result.openEditDialog(result.connections()[0]);
const succeeded = await result.testCurrentForm();
expect(succeeded).toBe(true);
expect(notificationStore.warning).toHaveBeenCalledWith(
'VMware HostSystem recent events request failed with HTTP 500',
);
expect(notificationStore.success).not.toHaveBeenCalled();
expect(result.connectionFailure()).toBeNull();
expect(result.connectionTestWarning()).toEqual({
title: 'VMware connection has limited data access',
message: 'VMware HostSystem recent events request failed with HTTP 500',
guidance:
'Core inventory, authentication, and API compatibility checks succeeded using VI JSON 8.0.2.0. Pulse will keep monitoring and report affected optional data as degraded.',
});
});
it('saves a connection without requiring a monitored-system preview', async () => {
vi.mocked(VMwareAPI.listConnections)
.mockResolvedValueOnce([] as never)

View file

@ -5,6 +5,7 @@ import {
isRedactedVMwareSecret,
type VMwareConnection,
type VMwareConnectionInput,
type VMwareConnectionTestResult,
} from '@/api/vmware';
import { apiErrorCode, apiErrorDetailField, apiErrorStatus } from '@/api/responseUtils';
import { notificationStore } from '@/stores/notifications';
@ -32,6 +33,12 @@ export interface VMwareConnectionFormState {
hasStoredPassword: boolean;
}
export interface VMwareConnectionTestWarning {
title: string;
message: string;
guidance: string;
}
const REDACTED_SECRET = '********';
const createEmptyFormState = (): VMwareConnectionFormState => ({
@ -93,6 +100,25 @@ const getErrorMessage = (error: unknown, fallback: string): string => {
const getVMwareErrorMessage = (error: unknown, fallback: string): string =>
apiErrorDetailField(error, 'error') ?? getErrorMessage(error, fallback);
const buildConnectionTestWarning = (
result: VMwareConnectionTestResult,
): VMwareConnectionTestWarning | null => {
if (!result.degraded) return null;
const diagnostic =
result.issues.find((issue) => issue.message?.trim())?.message?.trim() ||
`${result.issueCount || result.issues.length || 1} optional VMware read${
(result.issueCount || result.issues.length || 1) === 1 ? '' : 's'
} could not be completed.`;
const release = result.viRelease?.trim();
return {
title: 'VMware connection has limited data access',
message: diagnostic,
guidance: `Core inventory, authentication, and API compatibility checks succeeded${
release ? ` using VI JSON ${release}` : ''
}. Pulse will keep monitoring and report affected optional data as degraded.`,
};
};
const monitoredSystemImpactPreviewUnavailableStateFromError = (error: unknown) => {
return buildMonitoredSystemImpactPreviewUnavailableState({
code: apiErrorCode(error),
@ -138,6 +164,8 @@ export function useVMwareSettingsPanelState() {
const [previewing, setPreviewing] = createSignal(false);
const [connectionFailure, setConnectionFailure] =
createSignal<VMwareConnectionFailurePresentation | null>(null);
const [connectionTestWarning, setConnectionTestWarning] =
createSignal<VMwareConnectionTestWarning | null>(null);
const [monitoredSystemPreview, setMonitoredSystemPreview] =
createSignal<MonitoredSystemLedgerPreviewResponse | null>(null);
const [monitoredSystemPreviewUnavailableState, setMonitoredSystemPreviewUnavailableState] =
@ -188,6 +216,7 @@ export function useVMwareSettingsPanelState() {
setEditingConnectionId(null);
setForm(createEmptyFormState());
setConnectionFailure(null);
setConnectionTestWarning(null);
setMonitoredSystemPreview(null);
setMonitoredSystemPreviewUnavailableState(null);
setMonitoredSystemPreviewGenericError(null);
@ -198,6 +227,7 @@ export function useVMwareSettingsPanelState() {
setEditingConnectionId(connection.id);
setForm(buildFormStateFromConnection(connection));
setConnectionFailure(null);
setConnectionTestWarning(null);
setMonitoredSystemPreview(null);
setMonitoredSystemPreviewUnavailableState(null);
setMonitoredSystemPreviewGenericError(null);
@ -209,6 +239,7 @@ export function useVMwareSettingsPanelState() {
setEditingConnectionId(null);
setForm(createEmptyFormState());
setConnectionFailure(null);
setConnectionTestWarning(null);
setMonitoredSystemPreview(null);
setMonitoredSystemPreviewUnavailableState(null);
setMonitoredSystemPreviewGenericError(null);
@ -236,6 +267,7 @@ export function useVMwareSettingsPanelState() {
const updateForm = (patch: Partial<VMwareConnectionFormState>) => {
setConnectionFailure(null);
setConnectionTestWarning(null);
setMonitoredSystemPreview(null);
setMonitoredSystemPreviewUnavailableState(null);
setMonitoredSystemPreviewGenericError(null);
@ -245,14 +277,19 @@ export function useVMwareSettingsPanelState() {
const testCurrentForm = async () => {
setTesting(true);
setConnectionFailure(null);
setConnectionTestWarning(null);
try {
const payload = buildConnectionInput(form());
if (editingConnectionId()) {
await VMwareAPI.testSavedConnection(editingConnectionId()!, payload);
const result = editingConnectionId()
? await VMwareAPI.testSavedConnection(editingConnectionId()!, payload)
: await VMwareAPI.testConnection(payload);
const warning = buildConnectionTestWarning(result);
setConnectionTestWarning(warning);
if (warning) {
notificationStore.warning(warning.message);
} else {
await VMwareAPI.testConnection(payload);
notificationStore.success('VMware connection successful');
}
notificationStore.success('VMware connection successful');
return true;
} catch (error) {
const failure = buildVMwareConnectionFailurePresentation({
@ -273,10 +310,17 @@ export function useVMwareSettingsPanelState() {
const testSavedConnection = async (connection: VMwareConnection) => {
setTesting(true);
try {
await VMwareAPI.testSavedConnection(connection.id);
notificationStore.success(
`VMware connection successful for ${connection.name || connection.host}`,
);
const result = await VMwareAPI.testSavedConnection(connection.id);
const warning = buildConnectionTestWarning(result);
if (warning) {
notificationStore.warning(
`VMware connection for ${connection.name || connection.host} has limited data access: ${warning.message}`,
);
} else {
notificationStore.success(
`VMware connection successful for ${connection.name || connection.host}`,
);
}
} catch (error) {
const failure = buildVMwareConnectionFailurePresentation({
code: apiErrorCode(error),
@ -394,6 +438,7 @@ export function useVMwareSettingsPanelState() {
featureDisabledMessage,
form,
connectionFailure,
connectionTestWarning,
loadConnections,
loading,
loadingError,