diff --git a/ui/src/__tests__/system-task-view.test.js b/ui/src/__tests__/system-task-view.test.js
new file mode 100644
index 0000000..93d2c86
--- /dev/null
+++ b/ui/src/__tests__/system-task-view.test.js
@@ -0,0 +1,84 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { mount, flushPromises } from '@vue/test-utils'
+import { nextTick } from 'vue'
+
+// Runner list returned by GET rest/system/task.
+const RUNNERS = [
+ { key: 'importCatalogResource', label: 'ImportCatalogStatus', type: 'node', stats: { total: 3, running: 1, succeeded: 1, failed: 1 } },
+ { key: 'subRunner', label: 'SubStatus', type: 'subscription', stats: { total: 0, running: 0, succeeded: 0, failed: 0 } },
+]
+
+// Single shared fake data table so we can assert load() calls across the test.
+const dtStub = {
+ items: { value: [] }, totalItems: { value: 0 }, loading: { value: false },
+ search: { value: '' }, load: vi.fn(), loadAll: vi.fn(),
+}
+const useDataTableSpy = vi.fn(() => dtStub)
+
+vi.mock('@ligoj/host', () => ({
+ useApi: () => ({ get: vi.fn().mockResolvedValue(RUNNERS) }),
+ useAppStore: () => ({ setBreadcrumbs: vi.fn() }),
+ useI18nStore: () => ({ t: (k) => k, locale: 'en' }),
+ useDataTable: (...args) => useDataTableSpy(...args),
+ NodeIcon: { name: 'NodeIcon', props: ['node'], template: '' },
+ LjPageHeader: { template: '
' },
+ LjDialog: { props: ['modelValue'], template: '
' },
+ LjButton: { template: '' },
+ LjSegmented: { name: 'LjSegmented', props: ['modelValue', 'options'], emits: ['update:modelValue'], template: '' },
+ VibrantDataTable: { name: 'VibrantDataTable', props: ['headers', 'items', 'itemsLength', 'loading', 'defaultSort', 'defaultOrder'], template: '' },
+}))
+
+import SystemTaskView from '../views/SystemTaskView.vue'
+
+function mountView() {
+ return mount(SystemTaskView, { global: { stubs: { 'v-icon': true, 'v-progress-circular': true, 'v-tooltip': true } } })
+}
+
+describe('SystemTaskView', () => {
+ beforeEach(() => {
+ useDataTableSpy.mockClear()
+ dtStub.load.mockClear()
+ })
+
+ it('renders one card per runner, grouped, with stats', async () => {
+ const w = mountView()
+ await flushPromises()
+ const cards = w.findAll('.card')
+ expect(cards).toHaveLength(2)
+ // Two non-empty sections (node + subscription).
+ expect(w.findAll('.section')).toHaveLength(2)
+ // The node runner shows its total (3) and the succeeded segment is 1/3 wide.
+ expect(cards[0].text()).toContain('3')
+ const succeeded = cards[0].find('.seg.succeeded')
+ expect(succeeded.attributes('style')).toContain('33.3')
+ })
+
+ it('opens the dialog and wires useDataTable on the runner endpoint', async () => {
+ const w = mountView()
+ await flushPromises()
+ expect(w.find('.ljdialog').exists()).toBe(false)
+
+ await w.findAll('.card')[0].trigger('click')
+ expect(w.find('.ljdialog').exists()).toBe(true)
+ expect(useDataTableSpy).toHaveBeenCalledTimes(1)
+ const [endpoint, opts] = useDataTableSpy.mock.calls[0]
+ expect(endpoint).toBe('system/task/importCatalogResource')
+ expect(opts.defaultSort).toBe('start')
+ expect(opts.defaultOrder).toBe('desc')
+ })
+
+ it('passes the status filter through extraParams and reloads', async () => {
+ const w = mountView()
+ await flushPromises()
+ await w.findAll('.card')[0].trigger('click')
+
+ const opts = useDataTableSpy.mock.calls[0][1]
+ expect(opts.extraParams()).toEqual({})
+
+ // Change the status filter -> extraParams reflects it and the table reloads.
+ await w.findComponent({ name: 'LjSegmented' }).vm.$emit('update:modelValue', 'running')
+ await nextTick()
+ expect(opts.extraParams()).toEqual({ status: 'running' })
+ expect(dtStub.load).toHaveBeenCalled()
+ })
+})
diff --git a/ui/src/i18n/en.js b/ui/src/i18n/en.js
index 98cb783..1ad35e2 100644
--- a/ui/src/i18n/en.js
+++ b/ui/src/i18n/en.js
@@ -153,6 +153,32 @@ export default {
'system.config.sourcePrefix': 'Source: {source}',
'system.config.sourceOverridden': '{base} — overridden',
+ // System → Tasks
+ 'system.task.title': 'Tasks',
+ 'system.task.countLabel': 'task runners',
+ 'system.task.section.node': 'Node runners',
+ 'system.task.section.subscription': 'Subscription runners',
+ 'system.task.section.other': 'Other runners',
+ 'system.task.type.node': 'Node',
+ 'system.task.type.subscription': 'Subscription',
+ 'system.task.type.other': 'Other',
+ 'system.task.statTotal': 'total',
+ 'system.task.status.running': 'Running',
+ 'system.task.status.succeeded': 'Succeeded',
+ 'system.task.status.failed': 'Failed',
+ 'system.task.colStatus': 'Status',
+ 'system.task.colAuthor': 'Author',
+ 'system.task.colStart': 'Started',
+ 'system.task.colDuration': 'Duration',
+ 'system.task.colLocked': 'Locked entity',
+ 'system.task.filter.all': 'All',
+ 'system.task.noTask': 'No task',
+ 'system.task.stillRunning': 'Still running',
+ 'system.task.unit.d': 'd',
+ 'system.task.unit.h': 'h',
+ 'system.task.unit.m': 'min',
+ 'system.task.unit.s': 's',
+
// 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..6f77ce3 100644
--- a/ui/src/i18n/fr.js
+++ b/ui/src/i18n/fr.js
@@ -152,6 +152,32 @@ export default {
'system.config.sourcePrefix': 'Source : {source}',
'system.config.sourceOverridden': '{base} — surchargée',
+ // Système → Tâches
+ 'system.task.title': 'Tâches',
+ 'system.task.countLabel': 'exécuteurs de tâches',
+ 'system.task.section.node': 'Exécuteurs Nœud',
+ 'system.task.section.subscription': 'Exécuteurs Souscription',
+ 'system.task.section.other': 'Autres exécuteurs',
+ 'system.task.type.node': 'Nœud',
+ 'system.task.type.subscription': 'Souscription',
+ 'system.task.type.other': 'Autre',
+ 'system.task.statTotal': 'total',
+ 'system.task.status.running': 'En cours',
+ 'system.task.status.succeeded': 'Réussie',
+ 'system.task.status.failed': 'Échouée',
+ 'system.task.colStatus': 'Statut',
+ 'system.task.colAuthor': 'Auteur',
+ 'system.task.colStart': 'Début',
+ 'system.task.colDuration': 'Durée',
+ 'system.task.colLocked': 'Entité verrouillée',
+ 'system.task.filter.all': 'Toutes',
+ 'system.task.noTask': 'Aucune tâche',
+ 'system.task.stillRunning': 'Toujours en cours',
+ 'system.task.unit.d': 'j',
+ 'system.task.unit.h': 'h',
+ 'system.task.unit.m': 'min',
+ 'system.task.unit.s': '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..02198f0 100644
--- a/ui/src/index.js
+++ b/ui/src/index.js
@@ -73,6 +73,7 @@ import SystemPluginView from './views/SystemPluginView.vue'
import SystemNodeView from './views/SystemNodeView.vue'
import SystemCacheView from './views/SystemCacheView.vue'
import SystemBenchView from './views/SystemBenchView.vue'
+import SystemTaskView from './views/SystemTaskView.vue'
import ApiHomeView from './views/ApiHomeView.vue'
import ApiTokenView from './views/ApiTokenView.vue'
@@ -114,6 +115,7 @@ const routes = [
{ path: '/system/node', name: 'ui-system-node', component: SystemNodeView },
{ path: '/system/cache', name: 'ui-system-cache', component: SystemCacheView },
{ path: '/system/bench', name: 'ui-system-bench', component: SystemBenchView },
+ { path: '/system/task', name: 'ui-system-task', component: SystemTaskView },
{ path: '/api', name: 'ui-api', component: ApiHomeView },
{ path: '/api/token', name: 'ui-api-token', component: ApiTokenView },
diff --git a/ui/src/views/SystemTaskView.vue b/ui/src/views/SystemTaskView.vue
new file mode 100644
index 0000000..5af3cab
--- /dev/null
+++ b/ui/src/views/SystemTaskView.vue
@@ -0,0 +1,281 @@
+
+
+
+
+
+ {{ runners.length }} {{ t('system.task.countLabel') }}
+
+
+
+
mdi-alert-outline{{ error }}
+
+
+
+
+
+ {{ group.icon }}
+ {{ group.label }}
+ {{ group.runners.length }}
+
+
+
+
+
+
+
{{ t('common.noData') }}
+
+
+
+
+
+ {{ t('system.task.type.' + current.type) }}
+
+
+
+
+ {{ statusIcon(item.status) }}{{ t('system.task.status.' + item.status) }}
+
+
+ {{ item.author }}
+
+
+ {{ relTime(item.start) }}
+ {{ fullDate(item.start) }}
+
+
+
+ {{ durationLabel(item) }}
+ {{ durationTip(item) }}
+
+
+
+
+
+
+ {{ item.locked.project.name }}
+
+ {{ item.locked.node }}
+
+
+
+
+
+ {{ t('common.close') }}
+
+
+
+
+
+
+
+