diff --git a/ui/src/__tests__/system-configuration-source.test.js b/ui/src/__tests__/system-configuration-source.test.js
new file mode 100644
index 0000000..602d0bd
--- /dev/null
+++ b/ui/src/__tests__/system-configuration-source.test.js
@@ -0,0 +1,87 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { setActivePinia, createPinia } from 'pinia'
+import { mount, flushPromises } from '@vue/test-utils'
+import SystemConfigurationView from '../views/SystemConfigurationView.vue'
+
+// The Source column renders icon-only; the readable name + business
+// explanation live in a v-tooltip (project convention: tooltips are
+// v-tooltip, never native `title`). We drive rows through the mocked
+// fetch and stub the table so it renders the `cell.source` slot per row,
+// plus the tooltip default slot, so we can inspect what's rendered.
+const tableStub = {
+ name: 'VibrantDataTable',
+ props: ['items'],
+ template: '
',
+}
+const vtooltipStub = { template: '' }
+const viconStub = { template: '' }
+
+// One row per known source type + a classpath-style path + an unknown type.
+const ROWS = [
+ { name: 'a', source: 'systemEnvironment', overridden: false },
+ { name: 'b', source: 'systemProperties:foo', overridden: false },
+ { name: 'c', source: 'database', overridden: true },
+ { name: 'd', source: 'applicationConfig', overridden: false },
+ { name: 'e', source: 'jar:file:/x/classpath/app.cfg', overridden: false },
+ { name: 'f', source: 'totally-unknown-xyz', overridden: false },
+]
+
+function mountView() {
+ return mount(SystemConfigurationView, {
+ global: {
+ stubs: {
+ LjPageHeader: true, LjSearch: true, LjButton: true, LjDialog: true,
+ LjAvailabilityField: true, LigojConfirmDialog: true, RowActionsCog: true,
+ VibrantDataTable: tableStub,
+ 'v-tooltip': vtooltipStub, 'v-icon': viconStub, 'v-form': true, 'v-textarea': true,
+ },
+ },
+ })
+}
+
+describe('SystemConfigurationView — source column', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ globalThis.fetch = vi.fn().mockResolvedValue({
+ ok: true, status: 200,
+ headers: { get: () => 'application/json' },
+ json: async () => ROWS,
+ text: async () => '',
+ clone() { return this },
+ })
+ })
+
+ it('renders the source as an icon only (no text label)', async () => {
+ const w = mountView()
+ await flushPromises()
+ expect(w.findAll('.srccell').length).toBe(ROWS.length)
+ // The legacy text label is gone everywhere.
+ expect(w.find('.src-txt').exists()).toBe(false)
+ // Every source pill carries exactly its icon.
+ expect(w.findAll('.srcpill .vi').length).toBe(ROWS.length)
+ })
+
+ it('exposes a tooltip with the bold name and a business explanation', async () => {
+ const w = mountView()
+ await flushPromises()
+ const first = w.findAll('.srccell')[0]
+ expect(first.find('.srcpill .vtt').exists()).toBe(true)
+ expect(first.find('.src-tip-name').text()).toBe('System Environment')
+ expect(first.find('.src-tip-exp').exists()).toBe(true)
+ })
+
+ it('falls back to the raw source string for unknown types', async () => {
+ const w = mountView()
+ await flushPromises()
+ const unknown = w.findAll('.srccell').at(-1)
+ expect(unknown.find('.src-tip-exp').text()).toBe('totally-unknown-xyz')
+ })
+
+ it('converts the overridden indicator title into a v-tooltip', async () => {
+ const w = mountView()
+ await flushPromises()
+ const overriddenCell = w.findAll('.srccell')[2] // row 'c' (overridden)
+ expect(overriddenCell.find('.ovr .vtt').exists()).toBe(true)
+ expect(w.find('.ovr[title]').exists()).toBe(false)
+ })
+})
diff --git a/ui/src/i18n/en.js b/ui/src/i18n/en.js
index 98cb783..5329f7d 100644
--- a/ui/src/i18n/en.js
+++ b/ui/src/i18n/en.js
@@ -152,6 +152,11 @@ export default {
'system.config.tipOverridden': 'Overridden',
'system.config.sourcePrefix': 'Source: {source}',
'system.config.sourceOverridden': '{base} — overridden',
+ 'system.config.source.systemEnvironment': 'Operating-system environment variable.',
+ 'system.config.source.systemProperties': 'Java system property (-D…).',
+ 'system.config.source.applicationConfig': 'Application configuration file.',
+ 'system.config.source.database': 'Stored in the Ligoj database (editable here).',
+ 'system.config.source.classpath': 'Bundled in the application classpath.',
// System → Cache
'system.cache.title': 'Caches',
diff --git a/ui/src/i18n/fr.js b/ui/src/i18n/fr.js
index d21f0b2..b9d77ec 100644
--- a/ui/src/i18n/fr.js
+++ b/ui/src/i18n/fr.js
@@ -151,6 +151,11 @@ export default {
'system.config.tipOverridden': 'Surchargée',
'system.config.sourcePrefix': 'Source : {source}',
'system.config.sourceOverridden': '{base} — surchargée',
+ 'system.config.source.systemEnvironment': 'Variable d\'environnement du système d\'exploitation.',
+ 'system.config.source.systemProperties': 'Propriété système Java (-D…).',
+ 'system.config.source.applicationConfig': 'Fichier de configuration de l\'application.',
+ 'system.config.source.database': 'Stockée en base Ligoj (modifiable ici).',
+ 'system.config.source.classpath': 'Embarquée dans le classpath de l\'application.',
// Système → Cache
'system.cache.title': 'Caches',
diff --git a/ui/src/views/SystemConfigurationView.vue b/ui/src/views/SystemConfigurationView.vue
index f9b79b5..f6d86c2 100644
--- a/ui/src/views/SystemConfigurationView.vue
+++ b/ui/src/views/SystemConfigurationView.vue
@@ -64,10 +64,19 @@
{{ item.value }}
-
- {{ sourceIcon(item.source) }}{{ sourceLabel(item.source) }}
+
+ {{ sourceIcon(item.source) }}
+
+
+ {{ sourceLabel(item.source) }}
+ {{ sourceExplanation(item.source) }}
+
+
+
+
+ mdi-alert
+
- mdi-alert
@@ -194,15 +203,32 @@ const SOURCE_ICONS = {
database: 'mdi-database',
classpath: 'mdi-file-code-outline',
}
+const SOURCE_EXPLANATIONS = {
+ systemEnvironment: 'system.config.source.systemEnvironment',
+ systemProperties: 'system.config.source.systemProperties',
+ applicationConfig: 'system.config.source.applicationConfig',
+ database: 'system.config.source.database',
+ classpath: 'system.config.source.classpath',
+}
+// Map a raw source string to its known type key (shared by icon + tooltip):
+// classpath wins if mentioned anywhere, otherwise the prefix before ':'.
+function sourceKey(source) {
+ if (!source) return null
+ return source.includes('classpath') ? 'classpath' : source.split(':')[0]
+}
function sourceIcon(source) {
- if (!source) return 'mdi-help-circle-outline'
- const key = source.split(':')[0]
- return SOURCE_ICONS[source.includes('classpath') ? 'classpath' : key] || 'mdi-help-circle-outline'
+ return SOURCE_ICONS[sourceKey(source)] || 'mdi-help-circle-outline'
}
function sourceLabel(source) {
if (!source) return '—'
return source.split(':')[0].replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase()).trim()
}
+// Business explanation shown in the tooltip; unknown types fall back to the
+// raw source string so nothing is hidden from the operator.
+function sourceExplanation(source) {
+ const key = sourceKey(source)
+ return SOURCE_EXPLANATIONS[key] ? t(SOURCE_EXPLANATIONS[key]) : (source || '')
+}
/* --- crypto helper --- */
const cryptoOpen = ref(false)
@@ -336,9 +362,13 @@ onMounted(() => {
.kname { font-family: var(--mono); font-size: 12.5px; font-weight: 600; color: var(--ink); word-break: break-all; }
.masked { font-family: var(--mono); color: var(--ink-3); letter-spacing: .1em; }
.cval { font-family: var(--mono); font-size: 12.5px; color: var(--ink-2); background: var(--pill); padding: 2px 8px; border-radius: var(--radius-sm); display: inline-block; max-width: 460px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; }
-.srcpill { display: inline-flex; align-items: center; gap: 5px; max-width: 240px; font-family: var(--font); font-weight: 700; font-size: 11px; padding: 3px 10px; border-radius: 999px; color: var(--ink-2); background: var(--pill); }
+/* Source column is icon-only (the name + business explanation live in the
+ hover tooltip): a compact circular badge sized to the glyph. */
+.srcpill { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 999px; color: var(--ink-2); background: var(--pill); cursor: default; }
.srcpill :deep(.v-icon) { flex: none; }
-.src-txt { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.src-tip { display: flex; flex-direction: column; gap: 2px; }
+.src-tip-name { font-weight: 700; }
+.src-tip-exp { font-size: 12px; opacity: .85; }
.ovr { color: #d9701a; margin-left: 5px; vertical-align: middle; }
/* Custom checkboxes inside the edit dialog (slotted content keeps this