diff --git a/ui/src/__tests__/system-hook-view.test.js b/ui/src/__tests__/system-hook-view.test.js
new file mode 100644
index 0000000..59946db
--- /dev/null
+++ b/ui/src/__tests__/system-hook-view.test.js
@@ -0,0 +1,96 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { setActivePinia, createPinia } from 'pinia'
+import { mount, flushPromises } from '@vue/test-utils'
+import { VibrantDataTable, VibrantConfirmDialog, useI18nStore } from '@ligoj/host'
+import SystemHookView from '../views/SystemHookView.vue'
+import enMessages from '../i18n/en.js'
+
+// One server-paginated hook row (DataTables shape). `match` is the JSON
+// string the view splits into the path / method columns.
+const HOOK = {
+ id: 1, name: 'deploy', command: 'echo hi', workingDirectory: '/opt/app',
+ match: '{"path":"system/.*","method":"POST"}', inject: [], timeout: 10, delay: 0,
+}
+
+function jsonResponse(body) {
+ return {
+ ok: true, status: 200,
+ headers: { get: () => 'application/json' },
+ clone() { return jsonResponse(body) },
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+ }
+}
+
+// Render-the-slot stubs so the header actions, table rows, dialogs and row
+// menu are reachable from the test (Vuetify is externalized in the plugin
+// build, hence the v-* stubs too).
+const stubs = {
+ LjPageHeader: { template: '
' },
+ LjSearch: { template: '' },
+ LjButton: { emits: ['click'], template: '' },
+ LjDialog: { props: ['modelValue'], template: '
' },
+ LjAvailabilityField: { template: '' },
+ VibrantConfirmDialog: { props: ['modelValue'], emits: ['confirm'], template: '
' },
+ RowActionsCog: { template: '
' },
+ VibrantDataTable: {
+ props: ['headers', 'items', 'itemsLength', 'loading', 'defaultSort'],
+ template: '',
+ },
+ 'v-form': { template: '' },
+ 'v-select': true, 'v-text-field': true, 'v-combobox': true, 'v-icon': true,
+}
+
+function mountView() {
+ return mount(SystemHookView, { global: { stubs } })
+}
+
+describe('SystemHookView', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ useI18nStore().merge(enMessages, 'en')
+ globalThis.fetch = vi.fn().mockResolvedValue(
+ jsonResponse({ recordsTotal: 1, recordsFiltered: 1, data: [HOOK] }),
+ )
+ })
+
+ it('renders the data table with the expected columns', () => {
+ const w = mountView()
+ const table = w.findComponent(VibrantDataTable)
+ expect(table.exists()).toBe(true)
+ expect(table.props('headers').map((h) => h.key)).toEqual(['name', 'method', 'path', 'command', 'delay'])
+ expect(table.props('defaultSort')).toBe('name')
+ })
+
+ it('queries rest/system/hook (name asc) when the table requests options', async () => {
+ const w = mountView()
+ await w.findComponent(VibrantDataTable).vm.$emit('update:options', { page: 1, itemsPerPage: 25, sortBy: [] })
+ await flushPromises()
+
+ const url = globalThis.fetch.mock.calls[0][0]
+ expect(url).toContain('rest/system/hook')
+ expect(url).toContain('sidx=name')
+ })
+
+ it('opens the create dialog when clicking New', async () => {
+ const w = mountView()
+ expect(w.find('.ljdialog').exists()).toBe(false)
+ await w.find('.ljbtn').trigger('click')
+ expect(w.find('.ljdialog').exists()).toBe(true)
+ })
+
+ it('deletes a hook through the confirm dialog (DELETE on its id)', async () => {
+ const w = mountView()
+ // Populate the table, then open the row's delete confirm and confirm it.
+ await w.findComponent(VibrantDataTable).vm.$emit('update:options', { page: 1, itemsPerPage: 25, sortBy: [] })
+ await flushPromises()
+
+ await w.find('.cog button.danger').trigger('click')
+ await w.findComponent(VibrantConfirmDialog).vm.$emit('confirm')
+ await flushPromises()
+
+ const delCall = globalThis.fetch.mock.calls.find((c) => c[1]?.method === 'DELETE')
+ expect(delCall).toBeTruthy()
+ expect(delCall[0]).toBe('rest/system/hook/1')
+ })
+})
diff --git a/ui/src/i18n/en.js b/ui/src/i18n/en.js
index 98cb783..0b853a8 100644
--- a/ui/src/i18n/en.js
+++ b/ui/src/i18n/en.js
@@ -153,6 +153,39 @@ export default {
'system.config.sourcePrefix': 'Source: {source}',
'system.config.sourceOverridden': '{base} — overridden',
+ // System → Hooks
+ 'system.hook.title': 'Hooks',
+ 'system.hook.countLabel': 'hooks',
+ 'system.hook.searchPlaceholder': 'Search hooks…',
+ 'system.hook.new': 'New hook',
+ 'system.hook.headerName': 'Name',
+ 'system.hook.headerMethod': 'Method',
+ 'system.hook.headerPath': 'Path',
+ 'system.hook.headerCommand': 'Command',
+ 'system.hook.headerDelay': 'Execution',
+ 'system.hook.methodAll': 'ALL',
+ 'system.hook.sync': 'Sync',
+ 'system.hook.async': 'Async',
+ 'system.hook.editTitle': 'Edit hook',
+ 'system.hook.newTitle': 'New hook',
+ 'system.hook.deleteTitle': 'Delete hook',
+ 'system.hook.deleteConfirmBefore': 'Delete the hook ',
+ 'system.hook.deleteConfirmAfter': '?',
+ 'system.hook.fieldName': 'Name',
+ 'system.hook.fieldMethod': 'Method',
+ 'system.hook.fieldPath': 'Path (regular expression)',
+ 'system.hook.fieldCommand': 'Command',
+ 'system.hook.fieldWorkingDirectory': 'Working directory',
+ 'system.hook.fieldInject': 'Injected values',
+ 'system.hook.fieldTimeout': 'Timeout (s)',
+ 'system.hook.fieldDelay': 'Delay (s)',
+ 'system.hook.pathHint': 'Matched against the REST path, e.g. system/.*',
+ 'system.hook.workingDirectoryHint': 'No whitespace allowed',
+ 'system.hook.injectHint': 'Configuration / secret names to inject (press Enter to add)',
+ 'system.hook.timeoutHint': 'Empty = default (ligoj.hook.timeout)',
+ 'system.hook.delayHint': '0 = synchronous execution',
+ 'system.hook.noSpace': 'Whitespace is not allowed',
+
// System → Cache
'system.cache.title': 'Caches',
'system.cache.headerName': 'Cache',
diff --git a/ui/src/i18n/fr.js b/ui/src/i18n/fr.js
index d21f0b2..28d8493 100644
--- a/ui/src/i18n/fr.js
+++ b/ui/src/i18n/fr.js
@@ -152,6 +152,39 @@ export default {
'system.config.sourcePrefix': 'Source : {source}',
'system.config.sourceOverridden': '{base} — surchargée',
+ // Système → Hooks
+ 'system.hook.title': 'Hooks',
+ 'system.hook.countLabel': 'hooks',
+ 'system.hook.searchPlaceholder': 'Rechercher des hooks…',
+ 'system.hook.new': 'Nouveau hook',
+ 'system.hook.headerName': 'Nom',
+ 'system.hook.headerMethod': 'Méthode',
+ 'system.hook.headerPath': 'Chemin',
+ 'system.hook.headerCommand': 'Commande',
+ 'system.hook.headerDelay': 'Exécution',
+ 'system.hook.methodAll': 'TOUTES',
+ 'system.hook.sync': 'Synchrone',
+ 'system.hook.async': 'Asynchrone',
+ 'system.hook.editTitle': 'Modifier le hook',
+ 'system.hook.newTitle': 'Nouveau hook',
+ 'system.hook.deleteTitle': 'Supprimer le hook',
+ 'system.hook.deleteConfirmBefore': 'Supprimer le hook ',
+ 'system.hook.deleteConfirmAfter': ' ?',
+ 'system.hook.fieldName': 'Nom',
+ 'system.hook.fieldMethod': 'Méthode',
+ 'system.hook.fieldPath': 'Chemin (expression régulière)',
+ 'system.hook.fieldCommand': 'Commande',
+ 'system.hook.fieldWorkingDirectory': 'Répertoire de travail',
+ 'system.hook.fieldInject': 'Valeurs injectées',
+ 'system.hook.fieldTimeout': 'Délai d\'expiration (s)',
+ 'system.hook.fieldDelay': 'Délai (s)',
+ 'system.hook.pathHint': 'Comparé au chemin REST, ex. system/.*',
+ 'system.hook.workingDirectoryHint': 'Aucun espace autorisé',
+ 'system.hook.injectHint': 'Noms de configuration / secrets à injecter (Entrée pour ajouter)',
+ 'system.hook.timeoutHint': 'Vide = valeur par défaut (ligoj.hook.timeout)',
+ 'system.hook.delayHint': '0 = exécution synchrone',
+ 'system.hook.noSpace': 'Les espaces ne sont pas autorisés',
+
// Système → Cache
'system.cache.title': 'Caches',
'system.cache.headerName': 'Cache',
diff --git a/ui/src/index.js b/ui/src/index.js
index 2b3f00a..656c7e1 100644
--- a/ui/src/index.js
+++ b/ui/src/index.js
@@ -67,6 +67,7 @@ import SystemInfoView from './views/SystemInfoView.vue'
import SystemUserLogView from './views/SystemUserLogView.vue'
import ActuatorView from './views/ActuatorView.vue'
import SystemConfigurationView from './views/SystemConfigurationView.vue'
+import SystemHookView from './views/SystemHookView.vue'
import SystemUserView from './views/SystemUserView.vue'
import SystemRoleView from './views/SystemRoleView.vue'
import SystemPluginView from './views/SystemPluginView.vue'
@@ -108,6 +109,7 @@ const routes = [
{ path: '/system/actuator', redirect: '/system/information/actuator/info' },
{ path: '/system/logs', redirect: '/system/information/actuator/logfile' },
{ path: '/system/configuration', name: 'ui-system-configuration', component: SystemConfigurationView },
+ { path: '/system/hook', name: 'ui-system-hook', component: SystemHookView },
{ path: '/system/user', name: 'ui-system-user', component: SystemUserView },
{ path: '/system/role', name: 'ui-system-role', component: SystemRoleView },
{ path: '/system/plugin', name: 'ui-system-plugin', component: SystemPluginView },
diff --git a/ui/src/views/SystemHookView.vue b/ui/src/views/SystemHookView.vue
new file mode 100644
index 0000000..4ad4f8b
--- /dev/null
+++ b/ui/src/views/SystemHookView.vue
@@ -0,0 +1,255 @@
+
+
+
+
+
+ {{ dt.totalItems.value }} {{ t('system.hook.countLabel') }}
+
+
+
+ {{ t('system.hook.new') }}
+
+
+
+
mdi-alert-outline{{ dt.error.value }}
+
+
+
+ {{ item.name }}
+
+
+ {{ hookMethod(item) || t('system.hook.methodAll') }}
+
+
+ {{ hookPath(item) || '—' }}
+
+
+ {{ item.command }}
+
+
+ mdi-flash{{ t('system.hook.sync') }}
+ mdi-timer-sand{{ t('system.hook.async') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('common.cancel') }}
+ {{ t('common.save') }}
+
+
+
+
+ {{ t('system.hook.deleteConfirmBefore') }}{{ deleteTarget?.name }}{{ t('system.hook.deleteConfirmAfter') }}
+
+
+
+
+
+
+