Implemented by Ralph autonomous loop. Iteration: 9 Co-Authored-By: Claude <noreply@anthropic.com>
628 lines
16 KiB
Vue
628 lines
16 KiB
Vue
<template>
|
|
<div class="server-logs-view" :class="{ 'mobile-layout': isMobile }">
|
|
<!-- US-110: Mobile Material Design Top Bar -->
|
|
<MobileTopBar
|
|
v-if="isMobile"
|
|
title="Loguri Server"
|
|
:show-menu="true"
|
|
:actions="mobileTopBarActions"
|
|
@menu-click="toggleMobileMenu"
|
|
@action-click="handleTopBarAction"
|
|
/>
|
|
|
|
<!-- US-110: Mobile Hamburger Menu -->
|
|
<Sidebar v-if="isMobile" v-model:visible="mobileMenuVisible" position="left" class="mobile-sidebar">
|
|
<template #header>
|
|
<div class="sidebar-header">
|
|
<span class="sidebar-title">ROA2WEB</span>
|
|
</div>
|
|
</template>
|
|
<div class="sidebar-menu">
|
|
<router-link to="/data-entry" class="sidebar-item">
|
|
<i class="pi pi-receipt"></i>
|
|
<span>Bonuri</span>
|
|
</router-link>
|
|
<router-link to="/reports/server-logs" class="sidebar-item active">
|
|
<i class="pi pi-file-edit"></i>
|
|
<span>Loguri Server</span>
|
|
</router-link>
|
|
<router-link to="/reports/dashboard" class="sidebar-item">
|
|
<i class="pi pi-chart-bar"></i>
|
|
<span>Rapoarte</span>
|
|
</router-link>
|
|
<router-link to="/settings" class="sidebar-item">
|
|
<i class="pi pi-cog"></i>
|
|
<span>Setări</span>
|
|
</router-link>
|
|
</div>
|
|
</Sidebar>
|
|
|
|
<!-- Desktop Header -->
|
|
<div class="stats-header" v-if="!isMobile">
|
|
<h1>
|
|
<i class="pi pi-file-edit"></i>
|
|
Server Logs
|
|
</h1>
|
|
<div class="actions">
|
|
<Dropdown
|
|
v-model="selectedFile"
|
|
:options="logFiles"
|
|
optionLabel="label"
|
|
optionValue="value"
|
|
placeholder="Select log file"
|
|
class="log-file-select"
|
|
/>
|
|
<InputText
|
|
v-model="filterText"
|
|
placeholder="Filter (e.g., ocr, error)"
|
|
class="filter-input"
|
|
@keyup.enter="loadLogs"
|
|
/>
|
|
<Dropdown
|
|
v-model="linesCount"
|
|
:options="linesOptions"
|
|
optionLabel="label"
|
|
optionValue="value"
|
|
class="lines-select"
|
|
/>
|
|
<Button
|
|
label="Refresh"
|
|
icon="pi pi-refresh"
|
|
@click="loadLogs"
|
|
:loading="loading"
|
|
/>
|
|
<Button
|
|
label="Auto"
|
|
:icon="autoRefresh ? 'pi pi-pause' : 'pi pi-play'"
|
|
:severity="autoRefresh ? 'warning' : 'secondary'"
|
|
@click="toggleAutoRefresh"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- US-110: Mobile Filter/Control Bar -->
|
|
<div v-if="isMobile" class="mobile-controls-bar">
|
|
<Dropdown
|
|
v-model="selectedFile"
|
|
:options="logFiles"
|
|
optionLabel="label"
|
|
optionValue="value"
|
|
placeholder="Log file"
|
|
class="mobile-log-select"
|
|
/>
|
|
<Dropdown
|
|
v-model="linesCount"
|
|
:options="linesOptions"
|
|
optionLabel="label"
|
|
optionValue="value"
|
|
class="mobile-lines-select"
|
|
/>
|
|
<Button
|
|
:icon="autoRefresh ? 'pi pi-pause' : 'pi pi-play'"
|
|
:severity="autoRefresh ? 'warning' : 'secondary'"
|
|
@click="toggleAutoRefresh"
|
|
class="mobile-auto-btn"
|
|
v-tooltip.bottom="autoRefresh ? 'Stop Auto' : 'Start Auto'"
|
|
/>
|
|
</div>
|
|
|
|
<Message v-if="error" severity="error" :closable="true" @close="error = null">
|
|
{{ error }}
|
|
</Message>
|
|
|
|
<Card class="logs-card">
|
|
<template #title>
|
|
<div class="logs-title">
|
|
<span>{{ selectedFile === 'backend-stderr' ? 'Errors & Warnings' : 'Info Logs' }}</span>
|
|
<Tag :value="`${logs.length} lines`" severity="info" />
|
|
<Tag v-if="debugInfo.file_size_kb !== null" :value="`${debugInfo.file_size_kb} KB`" severity="secondary" class="ml-2" />
|
|
<span v-if="autoRefresh" class="auto-refresh-indicator">
|
|
<i class="pi pi-spin pi-sync"></i> Auto-refresh: {{ autoRefreshInterval }}s
|
|
</span>
|
|
</div>
|
|
<div v-if="debugInfo.logs_path" class="logs-path">
|
|
<small><i class="pi pi-folder"></i> {{ debugInfo.logs_path }}</small>
|
|
</div>
|
|
</template>
|
|
<template #content>
|
|
<div v-if="loading && logs.length === 0" class="loading-container">
|
|
<ProgressSpinner />
|
|
<p>Loading logs...</p>
|
|
</div>
|
|
<div v-else-if="logs.length === 0" class="empty-logs">
|
|
<i class="pi pi-inbox"></i>
|
|
<p>No log entries found</p>
|
|
</div>
|
|
<div v-else class="logs-container" ref="logsContainer">
|
|
<pre class="logs-content"><code
|
|
v-for="(line, index) in logs"
|
|
:key="index"
|
|
:style="getLineStyle(line)"
|
|
>{{ line }}</code></pre>
|
|
</div>
|
|
</template>
|
|
</Card>
|
|
|
|
<!-- US-110: Mobile Bottom Navigation -->
|
|
<MobileBottomNav
|
|
v-if="isMobile"
|
|
:items="mobileBottomNavItems"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
|
import Button from 'primevue/button'
|
|
import Card from 'primevue/card'
|
|
import Dropdown from 'primevue/dropdown'
|
|
import InputText from 'primevue/inputtext'
|
|
import Message from 'primevue/message'
|
|
import ProgressSpinner from 'primevue/progressspinner'
|
|
import Tag from 'primevue/tag'
|
|
import Sidebar from 'primevue/sidebar'
|
|
import axios from 'axios'
|
|
|
|
// US-110: Mobile Material Design components
|
|
import MobileTopBar from '@shared/components/mobile/MobileTopBar.vue'
|
|
import MobileBottomNav from '@shared/components/mobile/MobileBottomNav.vue'
|
|
|
|
// System API - endpoint separat de reports
|
|
const systemApi = axios.create({
|
|
baseURL: import.meta.env.BASE_URL + 'api/system',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
|
|
// Add auth token
|
|
systemApi.interceptors.request.use((config) => {
|
|
const token = localStorage.getItem('access_token')
|
|
if (token) {
|
|
config.headers.Authorization = `Bearer ${token}`
|
|
}
|
|
return config
|
|
})
|
|
|
|
// State
|
|
const logs = ref([])
|
|
const loading = ref(false)
|
|
const error = ref(null)
|
|
const selectedFile = ref('backend-stderr')
|
|
const filterText = ref('')
|
|
const linesCount = ref(100)
|
|
const autoRefresh = ref(false)
|
|
const autoRefreshInterval = ref(5)
|
|
const logsContainer = ref(null)
|
|
const debugInfo = ref({
|
|
logs_path: null,
|
|
file_exists: true,
|
|
file_size_kb: null
|
|
})
|
|
|
|
let refreshTimer = null
|
|
|
|
// US-110: Mobile state
|
|
const isMobile = ref(window.innerWidth < 768)
|
|
const mobileMenuVisible = ref(false)
|
|
|
|
// US-110: Toggle mobile hamburger menu
|
|
const toggleMobileMenu = () => {
|
|
mobileMenuVisible.value = !mobileMenuVisible.value
|
|
}
|
|
|
|
// US-110: Mobile TopBar actions (refresh, export)
|
|
const mobileTopBarActions = computed(() => [
|
|
{
|
|
icon: 'pi pi-refresh',
|
|
label: 'Actualizează',
|
|
tooltip: 'Actualizează'
|
|
},
|
|
{
|
|
icon: 'pi pi-download',
|
|
label: 'Export',
|
|
tooltip: 'Export Loguri'
|
|
}
|
|
])
|
|
|
|
// US-110: Handle top bar action clicks
|
|
const handleTopBarAction = (action) => {
|
|
if (action.icon === 'pi pi-refresh') {
|
|
loadLogs()
|
|
} else if (action.icon === 'pi pi-download') {
|
|
exportLogs()
|
|
}
|
|
}
|
|
|
|
// US-110: Bottom nav items for MobileBottomNav component
|
|
const mobileBottomNavItems = computed(() => [
|
|
{ to: '/data-entry', icon: 'pi pi-receipt', label: 'Bonuri' },
|
|
{ to: '/reports/server-logs', icon: 'pi pi-file-edit', label: 'Loguri', active: true },
|
|
{ to: '/reports/dashboard', icon: 'pi pi-chart-bar', label: 'Rapoarte' },
|
|
{ to: '/settings', icon: 'pi pi-cog', label: 'Setări' }
|
|
])
|
|
|
|
// US-110: Handle window resize
|
|
const handleResize = () => {
|
|
isMobile.value = window.innerWidth < 768
|
|
}
|
|
|
|
// US-110: Export logs to text file
|
|
const exportLogs = () => {
|
|
if (logs.value.length === 0) return
|
|
|
|
const content = logs.value.join('\n')
|
|
const blob = new Blob([content], { type: 'text/plain' })
|
|
const url = URL.createObjectURL(blob)
|
|
const link = document.createElement('a')
|
|
link.href = url
|
|
link.download = `server-logs-${selectedFile.value}-${new Date().toISOString().slice(0, 10)}.txt`
|
|
document.body.appendChild(link)
|
|
link.click()
|
|
document.body.removeChild(link)
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
// Options
|
|
const logFiles = [
|
|
{ label: 'Errors (stderr)', value: 'backend-stderr' },
|
|
{ label: 'Info (stdout)', value: 'backend-stdout' }
|
|
]
|
|
|
|
const linesOptions = [
|
|
{ label: '50 lines', value: 50 },
|
|
{ label: '100 lines', value: 100 },
|
|
{ label: '200 lines', value: 200 },
|
|
{ label: '500 lines', value: 500 }
|
|
]
|
|
|
|
// Methods
|
|
const loadLogs = async () => {
|
|
loading.value = true
|
|
error.value = null
|
|
|
|
try {
|
|
const params = {
|
|
file: selectedFile.value,
|
|
lines: linesCount.value
|
|
}
|
|
if (filterText.value) {
|
|
params.filter = filterText.value
|
|
}
|
|
|
|
console.log('[ServerLogs] Fetching logs from:', systemApi.defaults.baseURL + '/logs', params)
|
|
const response = await systemApi.get('/logs', { params })
|
|
console.log('[ServerLogs] Response:', response.data)
|
|
|
|
logs.value = response.data.lines || []
|
|
debugInfo.value = {
|
|
logs_path: response.data.logs_path || null,
|
|
file_exists: response.data.file_exists ?? true,
|
|
file_size_kb: response.data.file_size_kb ?? null
|
|
}
|
|
|
|
// Scroll to bottom
|
|
await nextTick()
|
|
if (logsContainer.value) {
|
|
logsContainer.value.scrollTop = logsContainer.value.scrollHeight
|
|
}
|
|
} catch (err) {
|
|
console.error('[ServerLogs] Failed to load logs:', err)
|
|
console.error('[ServerLogs] Error details:', err.response?.data, err.message)
|
|
error.value = err.response?.data?.detail || err.message || 'Failed to load logs - check console for details'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const getLineStyle = (line) => {
|
|
const lineLower = line.toLowerCase()
|
|
const baseStyle = {
|
|
display: 'block',
|
|
padding: '3px 8px',
|
|
borderRadius: '2px',
|
|
fontFamily: "'Consolas', 'Monaco', 'Courier New', monospace",
|
|
fontSize: '0.8125rem',
|
|
lineHeight: '1.6'
|
|
}
|
|
|
|
if (lineLower.includes('error') || lineLower.includes('exception') || lineLower.includes('failed')) {
|
|
return { ...baseStyle, color: '#b91c1c', backgroundColor: 'rgba(185, 28, 28, 0.1)', fontWeight: '600' }
|
|
}
|
|
if (lineLower.includes('warning') || lineLower.includes('warn')) {
|
|
return { ...baseStyle, color: '#b45309', backgroundColor: 'rgba(180, 83, 9, 0.1)', fontWeight: '500' }
|
|
}
|
|
if (lineLower.includes('success') || lineLower.includes('completed')) {
|
|
return { ...baseStyle, color: '#047857', backgroundColor: 'rgba(4, 120, 87, 0.1)' }
|
|
}
|
|
// Default: pure black text on light background
|
|
return { ...baseStyle, color: '#000000', backgroundColor: 'rgba(0, 0, 0, 0.02)' }
|
|
}
|
|
|
|
const toggleAutoRefresh = () => {
|
|
autoRefresh.value = !autoRefresh.value
|
|
if (autoRefresh.value) {
|
|
startAutoRefresh()
|
|
} else {
|
|
stopAutoRefresh()
|
|
}
|
|
}
|
|
|
|
const startAutoRefresh = () => {
|
|
stopAutoRefresh()
|
|
refreshTimer = setInterval(() => {
|
|
loadLogs()
|
|
}, autoRefreshInterval.value * 1000)
|
|
}
|
|
|
|
const stopAutoRefresh = () => {
|
|
if (refreshTimer) {
|
|
clearInterval(refreshTimer)
|
|
refreshTimer = null
|
|
}
|
|
}
|
|
|
|
// Watchers
|
|
watch(selectedFile, () => {
|
|
loadLogs()
|
|
})
|
|
|
|
watch(linesCount, () => {
|
|
loadLogs()
|
|
})
|
|
|
|
// Lifecycle
|
|
onMounted(() => {
|
|
// US-110: Add resize listener for mobile detection
|
|
window.addEventListener('resize', handleResize)
|
|
loadLogs()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
// US-110: Remove resize listener
|
|
window.removeEventListener('resize', handleResize)
|
|
stopAutoRefresh()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.server-logs-view {
|
|
padding: var(--space-lg);
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.stats-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: var(--space-lg);
|
|
flex-wrap: wrap;
|
|
gap: var(--space-md);
|
|
}
|
|
|
|
.stats-header h1 {
|
|
margin: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-sm);
|
|
font-size: 1.5rem;
|
|
color: var(--color-text);
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
gap: var(--space-sm);
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
}
|
|
|
|
.log-file-select {
|
|
min-width: 150px;
|
|
}
|
|
|
|
.filter-input {
|
|
min-width: 180px;
|
|
}
|
|
|
|
.lines-select {
|
|
min-width: 120px;
|
|
}
|
|
|
|
.logs-card {
|
|
margin-top: var(--space-md);
|
|
}
|
|
|
|
.logs-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-md);
|
|
}
|
|
|
|
.logs-path {
|
|
margin-top: var(--space-xs);
|
|
color: var(--color-text-secondary, #6b7280);
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.logs-path i {
|
|
margin-right: var(--space-xs);
|
|
}
|
|
|
|
.ml-2 {
|
|
margin-left: 0.5rem;
|
|
}
|
|
|
|
.auto-refresh-indicator {
|
|
font-size: 0.875rem;
|
|
color: var(--color-warning);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-xs);
|
|
}
|
|
|
|
.loading-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
padding: var(--space-xl);
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.empty-logs {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
padding: var(--space-xl);
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.empty-logs i {
|
|
font-size: 3rem;
|
|
margin-bottom: var(--space-md);
|
|
}
|
|
|
|
.logs-container {
|
|
max-height: 600px;
|
|
overflow-y: auto;
|
|
background: var(--surface-card);
|
|
border: 1px solid var(--surface-border);
|
|
border-radius: var(--radius-md);
|
|
padding: var(--space-sm);
|
|
}
|
|
|
|
.logs-content {
|
|
margin: 0;
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
background: transparent;
|
|
}
|
|
|
|
/* ================================================
|
|
US-110: Mobile Layout Styles
|
|
================================================ */
|
|
|
|
.mobile-layout {
|
|
padding: 0;
|
|
padding-top: 56px; /* MobileTopBar height */
|
|
padding-bottom: 56px; /* MobileBottomNav height */
|
|
}
|
|
|
|
.mobile-layout .logs-card {
|
|
margin: var(--space-sm);
|
|
margin-top: var(--space-xs);
|
|
}
|
|
|
|
.mobile-layout .logs-container {
|
|
max-height: calc(100vh - 280px);
|
|
}
|
|
|
|
/* Mobile controls bar */
|
|
.mobile-controls-bar {
|
|
display: flex;
|
|
gap: var(--space-sm);
|
|
padding: var(--space-sm);
|
|
background: var(--surface-card);
|
|
border-bottom: 1px solid var(--surface-border);
|
|
align-items: center;
|
|
}
|
|
|
|
.mobile-log-select {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.mobile-lines-select {
|
|
width: 100px;
|
|
}
|
|
|
|
.mobile-auto-btn {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* Mobile sidebar styles */
|
|
.mobile-sidebar .sidebar-header {
|
|
padding: var(--space-md);
|
|
border-bottom: 1px solid var(--surface-border);
|
|
}
|
|
|
|
.mobile-sidebar .sidebar-title {
|
|
font-size: var(--text-lg);
|
|
font-weight: var(--font-bold);
|
|
color: var(--color-primary);
|
|
}
|
|
|
|
.mobile-sidebar .sidebar-menu {
|
|
padding: var(--space-sm) 0;
|
|
}
|
|
|
|
.mobile-sidebar .sidebar-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-md);
|
|
padding: var(--space-md) var(--space-lg);
|
|
color: var(--text-color);
|
|
text-decoration: none;
|
|
transition: background-color var(--transition-fast);
|
|
}
|
|
|
|
.mobile-sidebar .sidebar-item:hover,
|
|
.mobile-sidebar .sidebar-item:active {
|
|
background: var(--surface-hover);
|
|
}
|
|
|
|
.mobile-sidebar .sidebar-item.active {
|
|
background: var(--blue-50);
|
|
color: var(--color-primary);
|
|
}
|
|
|
|
.mobile-sidebar .sidebar-item i {
|
|
font-size: var(--text-xl);
|
|
width: 24px;
|
|
text-align: center;
|
|
}
|
|
|
|
/* ================================================
|
|
Dark Mode Support for Mobile
|
|
================================================ */
|
|
|
|
[data-theme="dark"] .mobile-controls-bar {
|
|
background: var(--surface-card);
|
|
border-bottom-color: var(--surface-border);
|
|
}
|
|
|
|
[data-theme="dark"] .mobile-sidebar .sidebar-item.active {
|
|
background: var(--blue-900);
|
|
color: var(--blue-400);
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
:root:not([data-theme]) .mobile-controls-bar {
|
|
background: var(--surface-card);
|
|
border-bottom-color: var(--surface-border);
|
|
}
|
|
|
|
:root:not([data-theme]) .mobile-sidebar .sidebar-item.active {
|
|
background: var(--blue-900);
|
|
color: var(--blue-400);
|
|
}
|
|
}
|
|
|
|
/* Responsive - Desktop */
|
|
@media (max-width: 768px) {
|
|
.stats-header {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.actions {
|
|
width: 100%;
|
|
}
|
|
|
|
.log-file-select,
|
|
.filter-input,
|
|
.lines-select {
|
|
flex: 1;
|
|
min-width: 100px;
|
|
}
|
|
}
|
|
</style>
|