Compare commits

...

1 Commits

Author SHA1 Message Date
Olivier Lambert
1ee1f7f1b1 fix(xo6/plugins): show why a plugin's configuration or test failed
When saving a plugin's configuration or testing a plugin failed, the drawer
stayed open with no message: the error only went to the browser console,
so the save or the test seemed to do nothing.

The drawer now shows the error above its form: the server's message, and
for an error of the REST API, its details, as VtsOperationErrorCard does
on the creation pages. The error is cleared when the drawer is submitted
or opened again.

Introduced by 666a99706 (#10593)
2026-11-07 18:40:13 +00:00
11 changed files with 200 additions and 4 deletions

View File

@@ -1089,10 +1089,12 @@
"plugin:configuration-of": "the configuration of {name}",
"plugin:configuration-presets": "Configuration presets",
"plugin:configuration-presets-info": "Use a predefined configuration to fill the form",
"plugin:configure-error": "The configuration couldn't be saved",
"plugin:delete-configuration-info": "The configuration of the plugin will be lost.",
"plugin:needs-configuration": "This plugin can't be loaded until it is configured.",
"plugin:needs-configuration-short": "Needs a configuration before it can be loaded.",
"plugin:not-configurable": "This plugin is not configurable",
"plugin:test-error": "The test failed",
"plugin:test-info": "Test the plugin with its saved configuration.",
"plugins": "Plugins",
"policy": "Policy",

View File

@@ -1089,10 +1089,12 @@
"plugin:configuration-of": "la configuration de {name}",
"plugin:configuration-presets": "Configurations prédéfinies",
"plugin:configuration-presets-info": "Utilisez une configuration prédéfinie pour remplir le formulaire",
"plugin:configure-error": "La configuration n'a pas pu être enregistrée",
"plugin:delete-configuration-info": "La configuration du plugin sera perdue.",
"plugin:needs-configuration": "Ce plugin ne peut pas être chargé tant qu'il n'est pas configuré.",
"plugin:needs-configuration-short": "Nécessite une configuration avant de pouvoir être chargé.",
"plugin:not-configurable": "Ce plugin n'est pas configurable",
"plugin:test-error": "Le test a échoué",
"plugin:test-info": "Testez le plugin avec sa configuration enregistrée.",
"plugins": "Plugins",
"policy": "Politique",

View File

@@ -1,4 +1,5 @@
import PluginConfigurationDrawer from '@/modules/plugin/components/drawer/PluginConfigurationDrawer.vue'
import PluginRunError from '@/modules/plugin/components/drawer/PluginRunError.vue'
import JsonSchemaField from '@/modules/plugin/components/json-schema-form/JsonSchemaField.vue'
import type { FrontXoPlugin } from '@/modules/plugin/remote-resources/use-xo-plugin-collection.ts'
import type { JsonSchema } from '@/modules/plugin/utils/json-schema.util.ts'
@@ -28,9 +29,9 @@ const GMAIL_PRESET = { from: { address: 'xo@gmail.com' }, transport: { host: 'sm
const configuredPlugin = createPlugin({ configurationSchema: SCHEMA, configuration: SAVED_CONFIGURATION })
function mountDrawer(plugin: FrontXoPlugin = configuredPlugin) {
function mountDrawer(plugin: FrontXoPlugin = configuredPlugin, error?: Error) {
return mount(PluginConfigurationDrawer, {
props: { plugin },
props: { plugin, error },
global: createGlobalTestConfig(),
attachTo: document.body,
})
@@ -99,3 +100,16 @@ it('lists the missing fields instead of confirming', async () => {
expect(wrapper.emitted('confirm')).toBeUndefined()
expect(wrapper.getComponent(VtsFormErrorSummary).props('errors')).toHaveLength(2)
})
it('shows no run error before a run failed', () => {
expect(mountDrawer().findComponent(PluginRunError).exists()).toBe(false)
})
it('shows the error of the failed run', () => {
const error = new Error('SMTP connection refused')
expect(mountDrawer(undefined, error).getComponent(PluginRunError).props()).toEqual({
title: t('plugin:configure-error'),
error,
})
})

View File

@@ -17,6 +17,7 @@
</UiButton>
</div>
</VtsInputWrapper>
<PluginRunError v-if="error !== undefined" :title="t('plugin:configure-error')" :error />
<VtsFormErrorSummary :errors="summaryErrors" />
<JsonSchemaField v-if="schema !== undefined" v-model="formValue" :schema :label="t('configuration')" required />
</template>
@@ -31,6 +32,7 @@
</template>
<script lang="ts" setup>
import PluginRunError from '@/modules/plugin/components/drawer/PluginRunError.vue'
import JsonSchemaField from '@/modules/plugin/components/json-schema-form/JsonSchemaField.vue'
import { useXoPluginConfigurationForm } from '@/modules/plugin/composables/use-xo-plugin-configuration-form.composable.ts'
import type { FrontXoPlugin } from '@/modules/plugin/remote-resources/use-xo-plugin-collection.ts'
@@ -45,6 +47,7 @@ import { useI18n } from 'vue-i18n'
const { plugin } = defineProps<{
plugin: FrontXoPlugin
error?: Error
}>()
const emit = defineEmits<{

View File

@@ -0,0 +1,64 @@
import PluginRunError from '@/modules/plugin/components/drawer/PluginRunError.vue'
import { ApiError } from '@/shared/error/api.error.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import UiAlert from '@core/components/ui/alert/UiAlert.vue'
import UiLogEntryViewer from '@core/components/ui/log-entry-viewer/UiLogEntryViewer.vue'
import { mount } from '@vue/test-utils'
const TITLE = "The configuration couldn't be saved"
// an error of a plugin, as the REST API answers it
const PLUGIN_ERROR = { error: 'no monitor is configured' }
// a validation error, as the REST API answers it: its `error` is not a message
const VALIDATION_ERROR = {
error: { body: { message: 'Value does not match any allowed schema', value: ['not an object'] } },
data: {},
}
function mountError(error: Error) {
return mount(PluginRunError, {
props: { title: TITLE, error },
global: createGlobalTestConfig(),
})
}
it('shows the message of the server, not the HTTP status text', () => {
const alert = mountError(new ApiError('Internal Server Error', { status: 500, cause: PLUGIN_ERROR })).getComponent(
UiAlert
)
expect(alert.props('accent')).toBe('danger')
expect(alert.text()).toContain(TITLE)
expect(alert.text()).toContain('no monitor is configured')
expect(alert.text()).not.toContain('Internal Server Error')
})
it('falls back to the HTTP status text when the server sent no message', () => {
expect(
mountError(new ApiError('Unprocessable Entity', { status: 422, cause: VALIDATION_ERROR }))
.getComponent(UiAlert)
.text()
).toContain('Unprocessable Entity')
expect(
mountError(new ApiError('Bad Gateway', { status: 502 }))
.getComponent(UiAlert)
.text()
).toContain('Bad Gateway')
})
it('shows the details of the server error', () => {
const viewer = mountError(
new ApiError('Unprocessable Entity', { status: 422, cause: VALIDATION_ERROR })
).getComponent(UiLogEntryViewer)
expect(viewer.props()).toMatchObject({ label: t('api-error-details'), accent: 'danger', content: VALIDATION_ERROR })
})
it('shows the message of an error that did not come from the server, with no details', () => {
const wrapper = mountError(new Error('Failed to fetch'))
expect(wrapper.getComponent(UiAlert).text()).toContain('Failed to fetch')
expect(wrapper.findComponent(UiLogEntryViewer).exists()).toBe(false)
})

View File

@@ -0,0 +1,48 @@
<template>
<div class="plugin-run-error">
<UiAlert accent="danger">
{{ title }}
<template #description>{{ message }}</template>
</UiAlert>
<UiLogEntryViewer
v-if="details !== undefined"
:label="t('api-error-details')"
accent="danger"
size="small"
:content="details"
/>
</div>
</template>
<script lang="ts" setup>
import { ApiError } from '@/shared/error/api.error.ts'
import UiAlert from '@core/components/ui/alert/UiAlert.vue'
import UiLogEntryViewer from '@core/components/ui/log-entry-viewer/UiLogEntryViewer.vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { error } = defineProps<{
title: string
error: Error
}>()
const { t } = useI18n()
// the REST API answers an error with `{ error, data?, info? }`: its message is more useful than the HTTP status text
const message = computed(() => {
const serverMessage = error instanceof ApiError ? error.cause?.error : undefined
return typeof serverMessage === 'string' ? serverMessage : error.message
})
const details = computed(() => (error instanceof ApiError ? error.cause : undefined))
</script>
<style lang="postcss" scoped>
.plugin-run-error {
display: flex;
flex-direction: column;
gap: 0.8rem;
margin-block: 0.8rem 2.4rem;
}
</style>

View File

@@ -1,3 +1,4 @@
import PluginRunError from '@/modules/plugin/components/drawer/PluginRunError.vue'
import PluginTestDrawer from '@/modules/plugin/components/drawer/PluginTestDrawer.vue'
import JsonSchemaField from '@/modules/plugin/components/json-schema-form/JsonSchemaField.vue'
import type { FrontXoPlugin } from '@/modules/plugin/remote-resources/use-xo-plugin-collection.ts'
@@ -20,9 +21,9 @@ const REQUIRED_TEST_SCHEMA = {
required: ['to'],
} satisfies JsonSchema
function mountDrawer(plugin: FrontXoPlugin = createPlugin({ testable: true, testSchema: TEST_SCHEMA })) {
function mountDrawer(plugin: FrontXoPlugin = createPlugin({ testable: true, testSchema: TEST_SCHEMA }), error?: Error) {
return mount(PluginTestDrawer, {
props: { plugin },
props: { plugin, error },
global: createGlobalTestConfig(),
attachTo: document.body,
})
@@ -84,3 +85,16 @@ it('lists the missing fields instead of confirming', async () => {
expect(wrapper.emitted('confirm')).toBeUndefined()
expect(wrapper.getComponent(VtsFormErrorSummary).props('errors')).toHaveLength(1)
})
it('shows no run error before a run failed', () => {
expect(mountDrawer().findComponent(PluginRunError).exists()).toBe(false)
})
it('shows the error of the failed run', () => {
const error = new Error('SMTP connection refused')
expect(mountDrawer(undefined, error).getComponent(PluginRunError).props()).toEqual({
title: t('plugin:test-error'),
error,
})
})

View File

@@ -6,6 +6,7 @@
<template #content>
<UiInfo accent="info" wrap>{{ t('plugin:test-info') }}</UiInfo>
<PluginRunError v-if="error !== undefined" :title="t('plugin:test-error')" :error />
<VtsFormErrorSummary :errors="summaryErrors" />
<JsonSchemaField
v-if="testSchema !== undefined"
@@ -26,6 +27,7 @@
</template>
<script lang="ts" setup>
import PluginRunError from '@/modules/plugin/components/drawer/PluginRunError.vue'
import JsonSchemaField from '@/modules/plugin/components/json-schema-form/JsonSchemaField.vue'
import { useXoPluginTestForm } from '@/modules/plugin/composables/use-xo-plugin-test-form.composable.ts'
import type { FrontXoPlugin } from '@/modules/plugin/remote-resources/use-xo-plugin-collection.ts'
@@ -38,6 +40,7 @@ import { useI18n } from 'vue-i18n'
const { plugin } = defineProps<{
plugin: FrontXoPlugin
error?: Error
}>()
const emit = defineEmits<{

View File

@@ -25,17 +25,23 @@ export function useXoPluginDrawerJob(
const { run, canRun, isRunning, errorMessage } = useJob(plugin, payload)
// the error of the last run, shown in the drawer, which stays open
const runError = ref<Error>()
const { open } = useOverlay({
component: drawer,
events: {
onConfirm: async (newPayload: unknown) => {
payload.value = newPayload
runError.value = undefined
try {
await run()
} catch (error) {
console.error(`Error when running a job on the plugin ${plugin.value.name}`, error)
runError.value = error instanceof Error ? error : new Error(String(error))
return KEEP_OVERLAY_OPEN
}
},
@@ -45,9 +51,12 @@ export function useXoPluginDrawerJob(
const props = reactiveComputed(() => ({
plugin: plugin.value,
error: runError.value,
}))
function openDrawer() {
runError.value = undefined
return open({ props })
}

View File

@@ -52,6 +52,10 @@ function getOverlayOptions(): OverlayOptions {
return useOverlayMock.mock.calls[0][0]
}
function getDrawerProps(): { plugin: FrontXoPlugin; error?: Error } {
return open.mock.lastCall![0].props
}
function getJobArgs() {
const [plugin, payload] = useJob.mock.calls[0] as [Ref<FrontXoPlugin>, Ref<unknown>]
@@ -115,4 +119,36 @@ describe('onConfirm', () => {
expect(response).toBe(KEEP_OVERLAY_OPEN)
})
it('gives the error of the failed job to the drawer', async () => {
const error = new Error('SMTP connection refused')
run.mockRejectedValue(error)
mountDrawerJob().openDrawer()
await getOverlayOptions().events.onConfirm(PAYLOAD)
expect(getDrawerProps().error).toBe(error)
})
it('clears the error when the job is run again', async () => {
run.mockRejectedValueOnce(new Error('SMTP connection refused'))
mountDrawerJob().openDrawer()
await getOverlayOptions().events.onConfirm(PAYLOAD)
run.mockImplementationOnce(async () => expect(getDrawerProps().error).toBeUndefined())
await getOverlayOptions().events.onConfirm(PAYLOAD)
expect(run).toHaveBeenCalledTimes(2)
})
it('clears the error when the drawer is opened again', async () => {
run.mockRejectedValue(new Error('SMTP connection refused'))
const drawerJob = mountDrawerJob()
drawerJob.openDrawer()
await getOverlayOptions().events.onConfirm(PAYLOAD)
drawerJob.openDrawer()
expect(getDrawerProps().error).toBeUndefined()
})
})

View File

@@ -20,6 +20,7 @@
> Users must be able to say: "I had this issue, happy to know it's fixed"
- [XO6/Pools] Pool side panel now shows the same pool name, UUID and icon as the pools table, and keeps the pool card when the pool is disconnected or unreachable (PR [#10714](https://github.com/vatesfr/xen-orchestra/pull/10714))
- [XO6/Plugins] When saving a plugin's configuration or testing a plugin fails, the drawer now shows why instead of staying open without any message (PR [#10736](https://github.com/vatesfr/xen-orchestra/pull/10736))
### Packages to release