mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Fix VMware connection test degradation handling
This commit is contained in:
parent
50bac2ccf1
commit
13c4b57885
17 changed files with 614 additions and 46 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue