From a5cd78f01fd04f8804987d563f8b1bb164221026 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Thu, 12 Mar 2026 14:57:54 +0100 Subject: [PATCH] feat: bug fixes, layout improvements, and new features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fixes: - Remove non-existent `role` column from admin users SQL query (A1) - Fix Nextcloud Talk chat API path v4 → v1 for messages/send/read (A2) - Fix ServiceModeTab sync: useState → useEffect to reflect DB state (A3) - Guard BookStack book_slug with book_id fallback to avoid broken URLs (A4) Layout & UI: - Chat panel: sticky full-height positioning, main content scrolls independently (B1) - Vehicle booking datetime inputs: explicit text color for dark mode (B2) - AnnouncementBanner moved into grid with full-width span (B3) Features: - Per-user widget visibility preferences stored in users.preferences JSONB (C1) - Link collections: grouped external links in admin UI and dashboard widget (C2) - Admin ping history: migration 026, checked_at timestamps, expandable history rows (C4) - Service mode end date picker with scheduled deactivation display (C5) - Vikunja startup config logging and configured:false warnings (C7) Co-Authored-By: Claude Sonnet 4.6 --- backend/src/app.ts | 1 + backend/src/controllers/config.controller.ts | 5 +- .../controllers/serviceMonitor.controller.ts | 13 +- .../src/controllers/settings.controller.ts | 33 +++- backend/src/controllers/vikunja.controller.ts | 3 + .../026_create_service_ping_history.sql | 9 + backend/src/routes/serviceMonitor.routes.ts | 1 + backend/src/routes/settings.routes.ts | 2 + backend/src/server.ts | 7 + backend/src/services/bookstack.service.ts | 6 +- backend/src/services/nextcloud.service.ts | 6 +- .../src/services/serviceMonitor.service.ts | 32 ++++ backend/src/services/settings.service.ts | 15 +- .../components/admin/ServiceManagerTab.tsx | 109 +++++++++-- .../src/components/admin/ServiceModeTab.tsx | 29 ++- frontend/src/components/chat/ChatPanel.tsx | 10 +- .../dashboard/AnnouncementBanner.tsx | 4 +- .../components/dashboard/DashboardLayout.tsx | 4 +- .../src/components/dashboard/LinksWidget.tsx | 63 +++++++ .../VehicleBookingQuickAddWidget.tsx | 2 + frontend/src/components/dashboard/index.ts | 1 + frontend/src/constants/widgets.ts | 17 ++ frontend/src/pages/AdminSettings.tsx | 171 +++++++++++++----- frontend/src/pages/Dashboard.tsx | 53 +++++- frontend/src/pages/Settings.tsx | 78 ++++++-- frontend/src/services/admin.ts | 3 +- frontend/src/services/settings.ts | 5 + frontend/src/types/admin.types.ts | 9 + frontend/src/types/config.types.ts | 7 + 29 files changed, 593 insertions(+), 105 deletions(-) create mode 100644 backend/src/database/migrations/026_create_service_ping_history.sql create mode 100644 frontend/src/components/dashboard/LinksWidget.tsx create mode 100644 frontend/src/constants/widgets.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index c7e6b43..f7ad65c 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -106,6 +106,7 @@ app.use('/api/vikunja', vikunjaRoutes); app.use('/api/config', configRoutes); app.use('/api/admin', serviceMonitorRoutes); app.use('/api/admin/settings', settingsRoutes); +app.use('/api/settings', settingsRoutes); app.use('/api/banners', bannerRoutes); // 404 handler diff --git a/backend/src/controllers/config.controller.ts b/backend/src/controllers/config.controller.ts index 6da7256..288e2ee 100644 --- a/backend/src/controllers/config.controller.ts +++ b/backend/src/controllers/config.controller.ts @@ -19,13 +19,14 @@ class ConfigController { if (environment.bookstack.url) envLinks.bookstack = environment.bookstack.url; if (environment.vikunja.url) envLinks.vikunja = environment.vikunja.url; - const customLinks = await settingsService.getExternalLinks(); + const linkCollections = await settingsService.getExternalLinks(); res.status(200).json({ success: true, data: { ...envLinks, - customLinks, + customLinks: linkCollections.flatMap(c => c.links), + linkCollections, }, }); } diff --git a/backend/src/controllers/serviceMonitor.controller.ts b/backend/src/controllers/serviceMonitor.controller.ts index eb1dd0f..be9e8ee 100644 --- a/backend/src/controllers/serviceMonitor.controller.ts +++ b/backend/src/controllers/serviceMonitor.controller.ts @@ -137,7 +137,7 @@ class ServiceMonitorController { async getUsers(_req: Request, res: Response): Promise { try { const result = await pool.query( - `SELECT id, email, name, role, authentik_groups as groups, is_active, last_login_at + `SELECT id, email, name, authentik_groups as groups, is_active, last_login_at FROM users ORDER BY name` ); res.json({ success: true, data: result.rows }); @@ -147,6 +147,17 @@ class ServiceMonitorController { } } + async getPingHistory(req: Request, res: Response): Promise { + try { + const { serviceId } = req.params; + const data = await serviceMonitorService.getPingHistory(serviceId as string); + res.json({ success: true, data }); + } catch (error) { + logger.error('Failed to get ping history', { error }); + res.status(500).json({ success: false, message: 'Failed to get ping history' }); + } + } + async broadcastNotification(req: Request, res: Response): Promise { try { const { titel, nachricht, schwere, targetGroup } = broadcastSchema.parse(req.body); diff --git a/backend/src/controllers/settings.controller.ts b/backend/src/controllers/settings.controller.ts index f1fc9c5..b372bcb 100644 --- a/backend/src/controllers/settings.controller.ts +++ b/backend/src/controllers/settings.controller.ts @@ -1,6 +1,7 @@ import { Request, Response } from 'express'; import { z } from 'zod'; import settingsService from '../services/settings.service'; +import pool from '../config/database'; import logger from '../utils/logger'; const updateSchema = z.object({ @@ -8,8 +9,12 @@ const updateSchema = z.object({ }); const externalLinkSchema = z.array(z.object({ + id: z.string().min(1), name: z.string().min(1).max(200), - url: z.string().url().max(500), + links: z.array(z.object({ + name: z.string().min(1).max(200), + url: z.string().url().max(500), + })), })); class SettingsController { @@ -57,6 +62,32 @@ class SettingsController { res.status(500).json({ success: false, message: 'Failed to update setting' }); } } + async getUserPreferences(req: Request, res: Response): Promise { + try { + const userId = (req as any).user.id; + const result = await pool.query('SELECT preferences FROM users WHERE id = $1', [userId]); + const prefs = result.rows[0]?.preferences ?? {}; + res.json({ success: true, data: prefs }); + } catch (error) { + logger.error('Failed to get user preferences', { error }); + res.status(500).json({ success: false, message: 'Failed to get user preferences' }); + } + } + + async updateUserPreferences(req: Request, res: Response): Promise { + try { + const userId = (req as any).user.id; + const preferences = req.body; + await pool.query( + 'UPDATE users SET preferences = $1 WHERE id = $2', + [JSON.stringify(preferences), userId] + ); + res.json({ success: true }); + } catch (error) { + logger.error('Failed to update user preferences', { error }); + res.status(500).json({ success: false, message: 'Failed to update user preferences' }); + } + } } export default new SettingsController(); diff --git a/backend/src/controllers/vikunja.controller.ts b/backend/src/controllers/vikunja.controller.ts index 9247831..705f807 100644 --- a/backend/src/controllers/vikunja.controller.ts +++ b/backend/src/controllers/vikunja.controller.ts @@ -7,6 +7,7 @@ import logger from '../utils/logger'; class VikunjaController { async getMyTasks(_req: Request, res: Response): Promise { if (!environment.vikunja.url) { + logger.warn('Vikunja not configured – VIKUNJA_URL is empty'); res.status(200).json({ success: true, data: [], configured: false }); return; } @@ -21,6 +22,7 @@ class VikunjaController { async getOverdueTasks(req: Request, res: Response): Promise { if (!environment.vikunja.url) { + logger.warn('Vikunja not configured – VIKUNJA_URL is empty'); res.status(200).json({ success: true, data: [], configured: false }); return; } @@ -53,6 +55,7 @@ class VikunjaController { async getProjects(_req: Request, res: Response): Promise { if (!environment.vikunja.url) { + logger.warn('Vikunja not configured – VIKUNJA_URL is empty'); res.status(200).json({ success: true, data: [], configured: false }); return; } diff --git a/backend/src/database/migrations/026_create_service_ping_history.sql b/backend/src/database/migrations/026_create_service_ping_history.sql new file mode 100644 index 0000000..c4b1321 --- /dev/null +++ b/backend/src/database/migrations/026_create_service_ping_history.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS service_ping_history ( + id SERIAL PRIMARY KEY, + service_id VARCHAR(255) NOT NULL, + status VARCHAR(50) NOT NULL, + response_time_ms INTEGER, + checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_sph_service_checked ON service_ping_history(service_id, checked_at DESC); diff --git a/backend/src/routes/serviceMonitor.routes.ts b/backend/src/routes/serviceMonitor.routes.ts index 0eeba43..17cbd9b 100644 --- a/backend/src/routes/serviceMonitor.routes.ts +++ b/backend/src/routes/serviceMonitor.routes.ts @@ -9,6 +9,7 @@ const auth = [authenticate, requirePermission('admin:access')] as const; // Static routes first (before parameterized :id routes) router.get('/services/ping', ...auth, serviceMonitorController.pingAll.bind(serviceMonitorController)); router.get('/services/status-summary', ...auth, serviceMonitorController.getStatusSummary.bind(serviceMonitorController)); +router.get('/services/:serviceId/ping-history', ...auth, serviceMonitorController.getPingHistory.bind(serviceMonitorController)); router.get('/services', ...auth, serviceMonitorController.getAll.bind(serviceMonitorController)); router.post('/services', ...auth, serviceMonitorController.create.bind(serviceMonitorController)); router.put('/services/:id', ...auth, serviceMonitorController.update.bind(serviceMonitorController)); diff --git a/backend/src/routes/settings.routes.ts b/backend/src/routes/settings.routes.ts index 4646050..3657f45 100644 --- a/backend/src/routes/settings.routes.ts +++ b/backend/src/routes/settings.routes.ts @@ -7,6 +7,8 @@ const router = Router(); const auth = [authenticate, requirePermission('admin:access')] as const; router.get('/', ...auth, settingsController.getAll.bind(settingsController)); +router.get('/preferences', authenticate, settingsController.getUserPreferences.bind(settingsController)); +router.put('/preferences', authenticate, settingsController.updateUserPreferences.bind(settingsController)); router.get('/:key', ...auth, settingsController.get.bind(settingsController)); router.put('/:key', ...auth, settingsController.update.bind(settingsController)); diff --git a/backend/src/server.ts b/backend/src/server.ts index a14524b..4039000 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -31,6 +31,13 @@ const startServer = async (): Promise => { environment: environment.nodeEnv, database: dbConnected ? 'connected' : 'disconnected', }); + + // Log integration status so operators can see what's configured + logger.info('Integration status', { + bookstack: !!environment.bookstack.url, + nextcloud: !!environment.nextcloudUrl, + vikunja: !!environment.vikunja.url, + }); }); // Graceful shutdown handling diff --git a/backend/src/services/bookstack.service.ts b/backend/src/services/bookstack.service.ts index deea166..5c41a57 100644 --- a/backend/src/services/bookstack.service.ts +++ b/backend/src/services/bookstack.service.ts @@ -98,7 +98,7 @@ async function getRecentPages(): Promise { const pages: BookStackPage[] = response.data?.data ?? []; return pages.map((p) => ({ ...p, - url: p.url && p.url.startsWith('http') ? p.url : `${bookstack.url}/books/${p.book_slug}/page/${p.slug}`, + url: p.url && p.url.startsWith('http') ? p.url : `${bookstack.url}/books/${p.book_slug || p.book_id}/page/${p.slug}`, })); } catch (error) { if (axios.isAxiosError(error)) { @@ -134,7 +134,7 @@ async function searchPages(query: string): Promise { slug: item.slug, book_id: item.book_id ?? 0, book_slug: item.book_slug ?? '', - url: item.url || `${bookstack.url}/books/${item.book_slug}/page/${item.slug}`, + url: item.url || `${bookstack.url}/books/${item.book_slug || item.book_id}/page/${item.slug}`, preview_html: item.preview_html ?? { content: '' }, tags: item.tags ?? [], })); @@ -189,7 +189,7 @@ async function getPageById(id: number): Promise { html: page.html ?? '', created_at: page.created_at, updated_at: page.updated_at, - url: page.url && page.url.startsWith('http') ? page.url : `${bookstack.url}/books/${page.book_slug}/page/${page.slug}`, + url: page.url && page.url.startsWith('http') ? page.url : `${bookstack.url}/books/${page.book_slug || page.book_id}/page/${page.slug}`, book: page.book, createdBy: page.created_by, updatedBy: page.updated_by, diff --git a/backend/src/services/nextcloud.service.ts b/backend/src/services/nextcloud.service.ts index 4a8a4f3..575c4b7 100644 --- a/backend/src/services/nextcloud.service.ts +++ b/backend/src/services/nextcloud.service.ts @@ -202,7 +202,7 @@ async function getMessages(token: string, loginName: string, appPassword: string try { const response = await axios.get( - `${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}`, + `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/chat/${encodeURIComponent(token)}`, { params: { lookIntoFuture: 0, limit: 50, setReadMarker: 0 }, headers: { @@ -251,7 +251,7 @@ async function sendMessage(token: string, message: string, loginName: string, ap try { await axios.post( - `${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}`, + `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/chat/${encodeURIComponent(token)}`, { message }, { headers: { @@ -288,7 +288,7 @@ async function markAsRead(token: string, loginName: string, appPassword: string) try { await axios.delete( - `${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}/read`, + `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/chat/${encodeURIComponent(token)}/read`, { headers: { 'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`, diff --git a/backend/src/services/serviceMonitor.service.ts b/backend/src/services/serviceMonitor.service.ts index de2321d..897a69e 100644 --- a/backend/src/services/serviceMonitor.service.ts +++ b/backend/src/services/serviceMonitor.service.ts @@ -18,6 +18,7 @@ export interface PingResult { status: 'up' | 'down'; latencyMs: number; error?: string; + checked_at: string; } export interface StatusSummary { @@ -92,6 +93,7 @@ class ServiceMonitorService { url, status: 'up', latencyMs: Date.now() - start, + checked_at: new Date().toISOString(), }; } catch (error) { // Treat any HTTP response (even 4xx) as "service is reachable" only for status.php-type endpoints @@ -105,6 +107,7 @@ class ServiceMonitorService { url, status: 'up', latencyMs: Date.now() - start, + checked_at: new Date().toISOString(), }; } } @@ -113,6 +116,7 @@ class ServiceMonitorService { url, status: 'down', latencyMs: Date.now() - start, + checked_at: new Date().toISOString(), error: axios.isAxiosError(error) ? `${error.code ?? 'ERROR'}: ${error.message}` : String(error), @@ -140,9 +144,37 @@ class ServiceMonitorService { }) ); + // Store ping results in history (fire-and-forget) + this.storePingResults(results).catch(() => {}); + return results; } + async getPingHistory(serviceId: string): Promise> { + const result = await pool.query( + 'SELECT * FROM service_ping_history WHERE service_id = $1 ORDER BY checked_at DESC LIMIT 20', + [serviceId] + ); + return result.rows; + } + + private async storePingResults(results: PingResult[]): Promise { + for (const r of results) { + const serviceId = r.name || r.url; + await pool.query( + 'INSERT INTO service_ping_history (service_id, status, response_time_ms, checked_at) VALUES ($1, $2, $3, $4)', + [serviceId, r.status, r.latencyMs, r.checked_at] + ); + // Keep only last 20 per service + await pool.query( + `DELETE FROM service_ping_history WHERE service_id = $1 AND id NOT IN ( + SELECT id FROM service_ping_history WHERE service_id = $1 ORDER BY checked_at DESC LIMIT 20 + )`, + [serviceId] + ); + } + } + async getStatusSummary(): Promise { const results = await this.pingAll(); return { diff --git a/backend/src/services/settings.service.ts b/backend/src/services/settings.service.ts index 0cf0e92..a8dda65 100644 --- a/backend/src/services/settings.service.ts +++ b/backend/src/services/settings.service.ts @@ -34,9 +34,20 @@ class SettingsService { return (result.rowCount ?? 0) > 0; } - async getExternalLinks(): Promise> { + async getExternalLinks(): Promise}>> { const setting = await this.get('external_links'); - return Array.isArray(setting?.value) ? setting.value : []; + const value = setting?.value; + if (!Array.isArray(value)) return []; + + // Already in LinkCollection[] format + if (value.length === 0) return []; + if (value[0] && 'links' in value[0]) return value; + + // Old flat format: Array<{name, url}> — migrate to single collection + const migrated = [{ id: 'default', name: 'Links', links: value }]; + // Persist migrated format (use system user id) + await this.set('external_links', migrated, 'system'); + return migrated; } } diff --git a/frontend/src/components/admin/ServiceManagerTab.tsx b/frontend/src/components/admin/ServiceManagerTab.tsx index 30d541c..1eb6366 100644 --- a/frontend/src/components/admin/ServiceManagerTab.tsx +++ b/frontend/src/components/admin/ServiceManagerTab.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import React, { useState } from 'react'; import { Box, Table, @@ -9,6 +9,7 @@ import { TableRow, Paper, Button, + Collapse, Dialog, DialogTitle, DialogContent, @@ -24,10 +25,12 @@ import { } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; import AddIcon from '@mui/icons-material/Add'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { adminApi } from '../../services/admin'; import { useNotification } from '../../contexts/NotificationContext'; -import type { PingResult } from '../../types/admin.types'; +import type { PingResult, PingHistoryEntry } from '../../types/admin.types'; function ServiceManagerTab() { const queryClient = useQueryClient(); @@ -36,6 +39,9 @@ function ServiceManagerTab() { const [newName, setNewName] = useState(''); const [newUrl, setNewUrl] = useState(''); const [refreshInterval, setRefreshInterval] = useState(15000); + const [expandedService, setExpandedService] = useState(null); + const [historyData, setHistoryData] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); const { data: services, isLoading: servicesLoading } = useQuery({ queryKey: ['admin', 'services'], @@ -79,6 +85,23 @@ function ServiceManagerTab() { return pingResults?.find((p) => p.url === url); }; + const toggleHistory = async (serviceName: string) => { + if (expandedService === serviceName) { + setExpandedService(null); + return; + } + setExpandedService(serviceName); + setHistoryLoading(true); + try { + const data = await adminApi.getPingHistory(serviceName); + setHistoryData(data); + } catch { + setHistoryData([]); + } finally { + setHistoryLoading(false); + } + }; + const handleCreate = () => { if (newName.trim() && newUrl.trim()) { createMutation.mutate({ name: newName.trim(), url: newUrl.trim() }); @@ -148,14 +171,20 @@ function ServiceManagerTab() { URL Typ Latenz + Geprüft Aktionen {allItems.map((item) => { const ping = getPingForUrl(item.url); + const isExpanded = expandedService === item.name; return ( - + + *': { borderBottom: isExpanded ? 'unset' : undefined } }} + onClick={() => toggleHistory(item.name)} + > {pingLoading ? ( @@ -177,23 +206,77 @@ function ServiceManagerTab() { {item.type} {ping ? `${ping.latencyMs}ms` : '-'} - {item.isCustom && ( - deleteMutation.mutate(item.id)} - disabled={deleteMutation.isPending} - > - + {ping?.checked_at ? new Date(ping.checked_at).toLocaleString('de-DE') : '-'} + + + + {item.isCustom && ( + { e.stopPropagation(); deleteMutation.mutate(item.id); }} + disabled={deleteMutation.isPending} + > + + + )} + + {isExpanded ? : } - )} + + + + + + Ping-Verlauf (letzte 20) + {historyLoading ? ( + + ) : historyData.length === 0 ? ( + Keine Historie vorhanden + ) : ( + + + + Status + Antwortzeit + Zeitpunkt + + + + {historyData.map((h) => ( + + + + {h.status} + + {h.response_time_ms != null ? `${h.response_time_ms}ms` : '-'} + {new Date(h.checked_at).toLocaleString('de-DE')} + + ))} + +
+ )} +
+
+
+
+
); })} {allItems.length === 0 && ( - Keine Services konfiguriert + Keine Services konfiguriert )}
diff --git a/frontend/src/components/admin/ServiceModeTab.tsx b/frontend/src/components/admin/ServiceModeTab.tsx index 7ddb031..9f2bdb4 100644 --- a/frontend/src/components/admin/ServiceModeTab.tsx +++ b/frontend/src/components/admin/ServiceModeTab.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Box, Card, CardContent, Typography, Switch, FormControlLabel, TextField, Button, Alert, CircularProgress, @@ -20,17 +20,19 @@ export default function ServiceModeTab() { const currentValue = setting?.value ?? { active: false, message: '' }; const [active, setActive] = useState(currentValue.active ?? false); const [message, setMessage] = useState(currentValue.message ?? ''); + const [endsAt, setEndsAt] = useState(''); // Sync state when data loads - useState(() => { + useEffect(() => { if (setting?.value) { setActive(setting.value.active ?? false); setMessage(setting.value.message ?? ''); + setEndsAt(setting.value.ends_at ?? ''); } - }); + }, [setting]); const mutation = useMutation({ - mutationFn: (value: { active: boolean; message: string }) => + mutationFn: (value: { active: boolean; message: string; ends_at?: string | null }) => settingsApi.update('service_mode', value), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['service-mode'] }); @@ -41,7 +43,7 @@ export default function ServiceModeTab() { }); const handleSave = () => { - mutation.mutate({ active, message }); + mutation.mutate({ active, message, ends_at: endsAt || null }); }; if (isLoading) { @@ -63,6 +65,12 @@ export default function ServiceModeTab() { )} + {active && endsAt && ( + + Wartungsmodus endet automatisch am {new Date(endsAt).toLocaleString('de-DE')}. + + )} + + setEndsAt(e.target.value)} + InputLabelProps={{ shrink: true }} + helperText="Optional: Wartungsmodus wird automatisch zu diesem Zeitpunkt deaktiviert." + sx={{ mb: 3, '& input': { color: 'text.primary' } }} + /> + + + + + ))} - +