Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions ui/src/__tests__/system-user-log-view.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { mount, flushPromises } from '@vue/test-utils'
import { VibrantDataTable } from '@ligoj/host'
import SystemUserLogView from '../views/SystemUserLogView.vue'

// Heavy host chrome (page header, table) is stubbed — we only verify the
// view wires useDataTable('user-log') and hands the right headers to the
// table, and that a table option change drives a GET against the endpoint.
function mountView() {
return mount(SystemUserLogView, {
global: { stubs: { LjPageHeader: true, VibrantDataTable: true } },
})
}

describe('SystemUserLogView', () => {
beforeEach(() => {
setActivePinia(createPinia())
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
recordsTotal: 1,
recordsFiltered: 1,
data: [{ id: 1, user: 'ligoj-admin', date: 1781632761013, message: 'Boom', url: '#/x' }],
}),
})
})

it('mounts and 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(['date', 'user', 'message', 'url'])
expect(table.props('defaultSort')).toBe('date')
expect(table.props('defaultOrder')).toBe('desc')
})

it('queries rest/user-log (date desc) when the table requests options', async () => {
const w = mountView()
await w.findComponent(VibrantDataTable).vm.$emit('update:options', { page: 1, itemsPerPage: 25, sortBy: [] })
await flushPromises()

expect(globalThis.fetch).toHaveBeenCalledTimes(1)
const url = globalThis.fetch.mock.calls[0][0]
expect(url).toContain('rest/user-log')
expect(url).toContain('sidx=date')
expect(url).toContain('sord=desc')
})
})
12 changes: 12 additions & 0 deletions ui/src/i18n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ export default {
'system.logs.error': 'Unable to load this log.',
'system.logs.lines': '{n} lines',
'system.logs.bottom': 'Scroll to bottom',

// System → Information → User logs
'system.userLog.title': 'User logs',
'system.userLog.subtitle': 'Browser errors reported by users',
'system.userLog.link': 'User logs',
'system.userLog.headerDate': 'Date',
'system.userLog.headerUser': 'User',
'system.userLog.headerMessage': 'Message',
'system.userLog.headerUrl': 'URL',
'system.userLog.filterFrom': 'From',
'system.userLog.filterTo': 'To',

'system.info.system': 'System',
'system.info.memory': 'Memory',
'system.info.memoryUsed': 'Used',
Expand Down
12 changes: 12 additions & 0 deletions ui/src/i18n/fr.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ export default {
'system.logs.error': 'Impossible de charger ce journal.',
'system.logs.lines': '{n} lignes',
'system.logs.bottom': 'Aller en bas',

// Système → Information → Journaux d'erreurs
'system.userLog.title': "Journaux d'erreurs",
'system.userLog.subtitle': 'Erreurs navigateur signalées par les utilisateurs',
'system.userLog.link': "Journaux d'erreurs",
'system.userLog.headerDate': 'Date',
'system.userLog.headerUser': 'Utilisateur',
'system.userLog.headerMessage': 'Message',
'system.userLog.headerUrl': 'URL',
'system.userLog.filterFrom': 'Du',
'system.userLog.filterTo': 'Au',

'system.info.system': 'Système',
'system.info.memory': 'Mémoire',
'system.info.memoryUsed': 'Utilisée',
Expand Down
2 changes: 2 additions & 0 deletions ui/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import ManualView from './views/ManualView.vue'

import SystemView from './views/SystemView.vue'
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 SystemUserView from './views/SystemUserView.vue'
Expand Down Expand Up @@ -91,6 +92,7 @@ const routes = [
// legacy SystemView reachable at `/system` so the path isn't a dead end.
{ path: '/system', name: 'ui-system', component: SystemView },
{ path: '/system/information', name: 'ui-system-information', component: SystemInfoView },
{ path: '/system/information/user-logs', name: 'ui-system-user-logs', component: SystemUserLogView },
// Actuator browser, nested under Information; one route per endpoint, default `info`.
{ path: '/system/information/actuator', redirect: '/system/information/actuator/info' },
{ path: '/system/information/actuator/:endpoint', name: 'ui-system-actuator', component: ActuatorView },
Expand Down
1 change: 1 addition & 0 deletions ui/src/views/SystemInfoView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<template #actions>
<LjButton variant="ghost" icon="mdi-text-box-outline" @click="router.push('/system/information/actuator/logfile')">{{ t('system.logs.link') }}</LjButton>
<LjButton variant="ghost" icon="mdi-gauge" @click="router.push('/system/information/actuator/info')">{{ t('system.actuator.link') }}</LjButton>
<LjButton variant="ghost" icon="mdi-clipboard-alert-outline" @click="router.push('/system/information/user-logs')">{{ t('system.userLog.link') }}</LjButton>
</template>
</LjPageHeader>

Expand Down
129 changes: 129 additions & 0 deletions ui/src/views/SystemUserLogView.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<!--
SystemUserLogView — 2026 "Vibrant" browser-error log viewer
(Administration → Information → User logs). Read-only port of the
SystemUserView pattern: useDataTable on 'user-log' for server-side
fetch / sort / paging, rendered through VibrantDataTable. No edit /
delete dialogs — these rows are produced by the front-end error
reporter (POST /rest/user-log) and only consulted here. Adds a
date-range filter (From / To) wired into the GET's from/to query.
-->
<template>
<div class="userlogs lj-surface">
<LjPageHeader :title="t('system.userLog.title')"
:crumbs="[{ icon: 'mdi-cog-outline', label: t('system.breadcrumb') }, { label: t('system.info.title'), to: '/system/information' }, { label: t('system.userLog.title'), current: true }]">
<template #subtitle>
<b>{{ dt.totalItems.value }}</b> · {{ t('system.userLog.subtitle') }}
</template>
<template #actions>
<div class="daterange">
<label class="dr-field">
<span class="dr-label">{{ t('system.userLog.filterFrom') }}</span>
<span class="dr-box"><v-icon size="15" class="dr-ic">mdi-calendar-start</v-icon><input v-model="fromDate" type="date" /></span>
</label>
<label class="dr-field">
<span class="dr-label">{{ t('system.userLog.filterTo') }}</span>
<span class="dr-box"><v-icon size="15" class="dr-ic">mdi-calendar-end</v-icon><input v-model="toDate" type="date" /></span>
</label>
</div>
</template>
</LjPageHeader>

<p v-if="dt.error.value" class="errline"><v-icon size="16">mdi-alert-outline</v-icon>{{ dt.error.value }}</p>

<VibrantDataTable :headers="headers" :items="dt.items.value" :items-length="dt.totalItems.value" :loading="dt.loading.value"
item-value="id" default-sort="date" default-order="desc" :empty-text="t('common.noData')"
:fetch-all="dt.loadAll" filename="user-logs.csv" @update:options="loadData">
<!-- date arrives as epoch milliseconds → localized display. -->
<template #cell.date="{ item }">
<span class="ul-date">{{ formatDate(item.date) }}</span>
</template>
<template #cell.user="{ item }">
<code class="ul-user">{{ item.user }}</code>
</template>
<template #cell.message="{ item }">
<span class="ul-msg" :title="item.message">{{ item.message }}</span>
</template>
<template #cell.url="{ item }">
<code v-if="item.url" class="ul-url">{{ item.url }}</code>
<span v-else class="dash">—</span>
</template>
</VibrantDataTable>
</div>
</template>

<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { useAppStore, useDataTable, useI18nStore } from '@ligoj/host'
import { VibrantDataTable, LjPageHeader } from '@ligoj/host'

const app = useAppStore()
const i18n = useI18nStore()
const t = i18n.t

// Date-range filter (inclusive). `to` covers the whole selected day.
const fromDate = ref('')
const toDate = ref('')
const fromMs = computed(() => (fromDate.value ? new Date(fromDate.value).getTime() : null))
const toMs = computed(() => (toDate.value ? new Date(toDate.value + 'T23:59:59').getTime() : null))

// The backend GET /rest/user-log speaks the legacy DataTables dialect
// (rows/page/sidx/sord) and returns { recordsTotal, recordsFiltered, data }.
// `extraParams` pins the live date bounds onto every call (null values are
// dropped by useDataTable, so an empty field adds no filter).
const dt = useDataTable('user-log', {
defaultSort: 'date',
defaultOrder: 'desc',
extraParams: () => ({ from: fromMs.value, to: toMs.value }),
})

let lastOptions = {}

const headers = computed(() => [
{ key: 'date', label: t('system.userLog.headerDate'), sortable: true, icon: 'mdi-clock-outline' },
{ key: 'user', label: t('system.userLog.headerUser'), sortable: true, icon: 'mdi-account' },
{ key: 'message', label: t('system.userLog.headerMessage'), sortable: false, icon: 'mdi-message-alert-outline' },
{ key: 'url', label: t('system.userLog.headerUrl'), sortable: false, icon: 'mdi-link-variant' },
])

function formatDate(v) {
if (v == null) return '—'
const d = new Date(typeof v === 'number' ? v : Number(v))
return Number.isNaN(d.getTime()) ? String(v) : d.toLocaleString()
}

function loadData(options) { lastOptions = options; dt.load(options) }

// Re-query from page 1 when the date range changes, keeping the current
// page size and sort.
watch([fromDate, toDate], () => {
dt.load({ page: 1, itemsPerPage: lastOptions.itemsPerPage || 25, sortBy: lastOptions.sortBy })
})

onMounted(() => {
app.setBreadcrumbs(
() => [{ title: t('nav.home'), to: '/' }, { title: t('system.breadcrumb') }, { title: t('system.info.title'), to: '/system/information' }, { title: t('system.userLog.title') }],
{ refresh: () => dt.load(lastOptions) },
)
})
</script>

<style scoped>
/* View-specific styling only — the header chrome and table come from the
shared host components + the global `.lj-surface` class (ink, pill,
radius, mono, surface, border vars). */
.errline { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; color: rgb(var(--v-theme-error)); margin: 0 0 14px; }

.daterange { display: inline-flex; align-items: flex-end; gap: 12px; }
.dr-field { display: flex; flex-direction: column; gap: 4px; }
.dr-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--ink-3); }
.dr-box { display: flex; align-items: center; gap: 7px; padding: 0 11px; height: 38px; border-radius: var(--radius-sm); border: var(--border-w) var(--lj-border-style, solid) var(--border-c); background: var(--surface); transition: border-color .15s; }
.dr-box:focus-within { border-color: var(--border-2); }
.dr-ic { color: var(--ink-3); }
.dr-box input { border: 0; outline: 0; background: transparent; color: var(--ink); font-family: var(--mono); font-size: 13px; min-width: 0; }

.ul-date { font-family: var(--mono); font-size: 12.5px; color: var(--ink-2); white-space: nowrap; }
.ul-user { font-family: var(--mono); font-size: 13px; font-weight: 600; color: var(--ink); }
.ul-msg { font-size: 13.5px; color: var(--ink); display: inline-block; max-width: 520px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; }
.ul-url { font-family: var(--mono); font-size: 12.5px; color: var(--ink-2); }
.dash { color: var(--ink-3); font-size: 13px; }
</style>