fix: dashboard layout, widget caching, and backend stability

Layout:
- Remove Container maxWidth cap so widgets scale fluidly on wide screens
- Fix ActivityFeed Card missing height:100% and overflow:hidden that caused
  the timeline connector pseudo-element to bleed outside the card boundary

Performance (frontend):
- Migrate VehicleDashboardCard, EquipmentDashboardCard, AtemschutzDashboardCard,
  UpcomingEventsWidget, and PersonalWarningsBanner from useEffect+useState to
  TanStack Query — cached for 5 min, so navigating back to the dashboard no
  longer re-fires all 9 API requests
- Add gcTime:10min and refetchOnWindowFocus:false to QueryClient defaults to
  prevent spurious refetches on tab-switch

Backend stability:
- Raise default RATE_LIMIT_MAX from 100 to 300 req/15min — the previous limit
  was easily exceeded by a single active user during normal dashboard navigation
- Increase DB connectionTimeoutMillis from 2s to 5s to handle burst-load
  scenarios where multiple requests compete for pool slots simultaneously

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-03-03 12:55:16 +01:00
parent d91f757f34
commit 02cf5138cf
10 changed files with 79 additions and 174 deletions

View File

@@ -16,7 +16,7 @@ const poolConfig: PoolConfig = {
password: environment.database.password, password: environment.database.password,
max: 20, // Maximum number of clients in the pool max: 20, // Maximum number of clients in the pool
idleTimeoutMillis: 30000, // Close idle clients after 30 seconds idleTimeoutMillis: 30000, // Close idle clients after 30 seconds
connectionTimeoutMillis: 2000, // Return an error if connection takes longer than 2 seconds connectionTimeoutMillis: 5000, // Return an error if connection takes longer than 5 seconds
}; };
const pool = new Pool(poolConfig); const pool = new Pool(poolConfig);

View File

@@ -54,7 +54,7 @@ const environment: EnvironmentConfig = {
}, },
rateLimit: { rateLimit: {
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes
max: parseInt(process.env.RATE_LIMIT_MAX || '100', 10), max: parseInt(process.env.RATE_LIMIT_MAX || '300', 10),
}, },
authentik: { authentik: {
issuer: process.env.AUTHENTIK_ISSUER || 'https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr-dashboard/', issuer: process.env.AUTHENTIK_ISSUER || 'https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr-dashboard/',

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React from 'react';
import { import {
Alert, Alert,
AlertTitle, AlertTitle,
@@ -10,6 +10,7 @@ import {
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom'; import { Link as RouterLink } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { atemschutzApi } from '../../services/atemschutz'; import { atemschutzApi } from '../../services/atemschutz';
import type { AtemschutzStats } from '../../types/atemschutz.types'; import type { AtemschutzStats } from '../../types/atemschutz.types';
@@ -20,31 +21,12 @@ interface AtemschutzDashboardCardProps {
const AtemschutzDashboardCard: React.FC<AtemschutzDashboardCardProps> = ({ const AtemschutzDashboardCard: React.FC<AtemschutzDashboardCardProps> = ({
hideWhenEmpty = false, hideWhenEmpty = false,
}) => { }) => {
const [stats, setStats] = useState<AtemschutzStats | null>(null); const { data: stats, isLoading, isError } = useQuery<AtemschutzStats>({
const [loading, setLoading] = useState(true); queryKey: ['atemschutz-stats'],
const [error, setError] = useState<string | null>(null); queryFn: () => atemschutzApi.getStats(),
});
useEffect(() => { if (isLoading) {
let mounted = true;
const fetchStats = async () => {
try {
setLoading(true);
setError(null);
const data = await atemschutzApi.getStats();
if (mounted) setStats(data);
} catch {
if (mounted) setError('Atemschutzstatus konnte nicht geladen werden.');
} finally {
if (mounted) setLoading(false);
}
};
fetchStats();
return () => {
mounted = false;
};
}, []);
if (loading) {
return ( return (
<Card> <Card>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 2 }}> <CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 2 }}>
@@ -57,12 +39,12 @@ const AtemschutzDashboardCard: React.FC<AtemschutzDashboardCardProps> = ({
); );
} }
if (error) { if (isError) {
return ( return (
<Card> <Card>
<CardContent> <CardContent>
<Typography variant="body2" color="error"> <Typography variant="body2" color="error">
{error} Atemschutzstatus konnte nicht geladen werden.
</Typography> </Typography>
</CardContent> </CardContent>
</Card> </Card>
@@ -71,7 +53,6 @@ const AtemschutzDashboardCard: React.FC<AtemschutzDashboardCardProps> = ({
if (!stats) return null; if (!stats) return null;
// Determine if there are any concerns
const hasConcerns = const hasConcerns =
stats.untersuchungAbgelaufen > 0 || stats.untersuchungAbgelaufen > 0 ||
stats.leistungstestAbgelaufen > 0 || stats.leistungstestAbgelaufen > 0 ||
@@ -80,7 +61,6 @@ const AtemschutzDashboardCard: React.FC<AtemschutzDashboardCardProps> = ({
const allGood = stats.einsatzbereit === stats.total && !hasConcerns; const allGood = stats.einsatzbereit === stats.total && !hasConcerns;
// If hideWhenEmpty and everything is fine, render nothing
if (hideWhenEmpty && allGood) return null; if (hideWhenEmpty && allGood) return null;
return ( return (

View File

@@ -89,7 +89,7 @@ const ActivityFeed: React.FC = () => {
}; };
return ( return (
<Card> <Card sx={{ height: '100%', overflow: 'hidden' }}>
<CardContent> <CardContent>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Letzte Aktivitäten Letzte Aktivitäten
@@ -110,7 +110,7 @@ const ActivityFeed: React.FC = () => {
position: 'absolute', position: 'absolute',
left: 19, left: 19,
top: 56, top: 56,
bottom: -8, bottom: 0,
width: 2, width: 2,
bgcolor: 'divider', bgcolor: 'divider',
} }

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React from 'react';
import { import {
Alert, Alert,
AlertTitle, AlertTitle,
@@ -10,6 +10,7 @@ import {
} from '@mui/material'; } from '@mui/material';
import { NotificationsActive as NotificationsActiveIcon } from '@mui/icons-material'; import { NotificationsActive as NotificationsActiveIcon } from '@mui/icons-material';
import { Link as RouterLink } from 'react-router-dom'; import { Link as RouterLink } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { atemschutzApi } from '../../services/atemschutz'; import { atemschutzApi } from '../../services/atemschutz';
import type { User } from '../../types/auth.types'; import type { User } from '../../types/auth.types';
import type { AtemschutzUebersicht } from '../../types/atemschutz.types'; import type { AtemschutzUebersicht } from '../../types/atemschutz.types';
@@ -74,33 +75,14 @@ interface PersonalWarningsBannerProps {
} }
const PersonalWarningsBanner: React.FC<PersonalWarningsBannerProps> = ({ user: _user, onWarningCount }) => { const PersonalWarningsBanner: React.FC<PersonalWarningsBannerProps> = ({ user: _user, onWarningCount }) => {
const [record, setRecord] = useState<AtemschutzUebersicht | null>(null); const { data: record, isLoading } = useQuery<AtemschutzUebersicht | null>({
const [loading, setLoading] = useState(true); queryKey: ['atemschutz-my-status'],
const [fetchError, setFetchError] = useState<string | null>(null); queryFn: () => atemschutzApi.getMyStatus(),
});
useEffect(() => {
let mounted = true;
const fetchStatus = async () => {
try {
setLoading(true);
setFetchError(null);
const data = await atemschutzApi.getMyStatus();
if (mounted) setRecord(data);
} catch {
if (mounted) setFetchError('Persönlicher Atemschutz-Status konnte nicht geladen werden.');
} finally {
if (mounted) setLoading(false);
}
};
fetchStatus();
return () => { mounted = false; };
}, []);
// ── Loading state ────────────────────────────────────────────────────────── // ── Loading state ──────────────────────────────────────────────────────────
if (loading) { if (isLoading) {
return ( return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1 }}>
<CircularProgress size={16} /> <CircularProgress size={16} />
@@ -111,13 +93,6 @@ const PersonalWarningsBanner: React.FC<PersonalWarningsBannerProps> = ({ user: _
); );
} }
// ── Fetch error ────────────────────────────────────────────────────────────
if (fetchError) {
// Non-critical — silently swallow so the dashboard still loads cleanly.
return null;
}
// ── No atemschutz record for this user ───────────────────────────────────── // ── No atemschutz record for this user ─────────────────────────────────────
if (!record) return null; if (!record) return null;

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React from 'react';
import { import {
Box, Box,
Card, Card,
@@ -13,6 +13,7 @@ import {
} from '@mui/material'; } from '@mui/material';
import { CalendarMonth as CalendarMonthIcon } from '@mui/icons-material'; import { CalendarMonth as CalendarMonthIcon } from '@mui/icons-material';
import { Link as RouterLink } from 'react-router-dom'; import { Link as RouterLink } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { trainingApi } from '../../services/training'; import { trainingApi } from '../../services/training';
import { eventsApi } from '../../services/events'; import { eventsApi } from '../../services/events';
import type { UebungListItem, UebungTyp } from '../../types/training.types'; import type { UebungListItem, UebungTyp } from '../../types/training.types';
@@ -106,52 +107,34 @@ const FETCH_LIMIT = 20; // fetch more than 5 so filtering from today leaves enou
const DISPLAY_LIMIT = 5; const DISPLAY_LIMIT = 5;
const UpcomingEventsWidget: React.FC = () => { const UpcomingEventsWidget: React.FC = () => {
const [entries, setEntries] = useState<CalendarEntry[]>([]); const { data: trainingItems = [], isLoading: trainingLoading, isError: trainingError } = useQuery<UebungListItem[]>({
const [loading, setLoading] = useState(true); queryKey: ['upcoming-trainings'],
const [error, setError] = useState<string | null>(null); queryFn: () => trainingApi.getUpcoming(FETCH_LIMIT),
});
useEffect(() => { const { data: eventItems = [], isLoading: eventsLoading, isError: eventsError } = useQuery<VeranstaltungListItem[]>({
let mounted = true; queryKey: ['upcoming-events'],
queryFn: () => eventsApi.getUpcoming(FETCH_LIMIT),
});
const fetchData = async () => { const loading = trainingLoading || eventsLoading;
try { const error = trainingError || eventsError;
setLoading(true);
setError(null);
const [trainingItems, eventItems] = await Promise.all([ const entries = React.useMemo(() => {
trainingApi.getUpcoming(FETCH_LIMIT), if (loading) return [];
eventsApi.getUpcoming(FETCH_LIMIT), const today = startOfToday();
]); return [
...trainingItems
if (!mounted) return; .filter((t) => !t.abgesagt)
.map(mapTraining),
const today = startOfToday(); ...eventItems
.filter((e) => !e.abgesagt)
const combined: CalendarEntry[] = [ .map(mapVeranstaltung),
...trainingItems ]
.filter((t) => !t.abgesagt) .filter((e) => e.date >= today)
.map(mapTraining), .sort((a, b) => a.date.getTime() - b.date.getTime())
...eventItems .slice(0, DISPLAY_LIMIT);
.filter((e) => !e.abgesagt) }, [trainingItems, eventItems, loading]);
.map(mapVeranstaltung),
]
.filter((e) => e.date >= today)
.sort((a, b) => a.date.getTime() - b.date.getTime())
.slice(0, DISPLAY_LIMIT);
setEntries(combined);
} catch {
if (mounted) setError('Termine konnten nicht geladen werden.');
} finally {
if (mounted) setLoading(false);
}
};
fetchData();
return () => {
mounted = false;
};
}, []);
// ── Loading state ───────────────────────────────────────────────────────── // ── Loading state ─────────────────────────────────────────────────────────
if (loading) { if (loading) {
@@ -177,7 +160,7 @@ const UpcomingEventsWidget: React.FC = () => {
<Typography variant="h6">Nächste Termine</Typography> <Typography variant="h6">Nächste Termine</Typography>
</Box> </Box>
<Typography variant="body2" color="error"> <Typography variant="body2" color="error">
{error} Termine konnten nicht geladen werden.
</Typography> </Typography>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React from 'react';
import { import {
Alert, Alert,
AlertTitle, AlertTitle,
@@ -10,6 +10,7 @@ import {
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom'; import { Link as RouterLink } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { equipmentApi } from '../../services/equipment'; import { equipmentApi } from '../../services/equipment';
import type { EquipmentStats } from '../../types/equipment.types'; import type { EquipmentStats } from '../../types/equipment.types';
@@ -20,31 +21,12 @@ interface EquipmentDashboardCardProps {
const EquipmentDashboardCard: React.FC<EquipmentDashboardCardProps> = ({ const EquipmentDashboardCard: React.FC<EquipmentDashboardCardProps> = ({
hideWhenEmpty = false, hideWhenEmpty = false,
}) => { }) => {
const [stats, setStats] = useState<EquipmentStats | null>(null); const { data: stats, isLoading, isError } = useQuery<EquipmentStats>({
const [loading, setLoading] = useState(true); queryKey: ['equipment-stats'],
const [error, setError] = useState<string | null>(null); queryFn: () => equipmentApi.getStats(),
});
useEffect(() => { if (isLoading) {
let mounted = true;
const fetchStats = async () => {
try {
setLoading(true);
setError(null);
const data = await equipmentApi.getStats();
if (mounted) setStats(data);
} catch {
if (mounted) setError('Ausrüstungsstatus konnte nicht geladen werden.');
} finally {
if (mounted) setLoading(false);
}
};
fetchStats();
return () => {
mounted = false;
};
}, []);
if (loading) {
return ( return (
<Card> <Card>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 2 }}> <CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 2 }}>
@@ -57,12 +39,12 @@ const EquipmentDashboardCard: React.FC<EquipmentDashboardCardProps> = ({
); );
} }
if (error) { if (isError) {
return ( return (
<Card> <Card>
<CardContent> <CardContent>
<Typography variant="body2" color="error"> <Typography variant="body2" color="error">
{error} Ausrüstungsstatus konnte nicht geladen werden.
</Typography> </Typography>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React from 'react';
import { import {
Alert, Alert,
AlertTitle, AlertTitle,
@@ -10,6 +10,7 @@ import {
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom'; import { Link as RouterLink } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { vehiclesApi } from '../../services/vehicles'; import { vehiclesApi } from '../../services/vehicles';
import { equipmentApi } from '../../services/equipment'; import { equipmentApi } from '../../services/equipment';
import type { VehicleStats, InspectionAlert } from '../../types/vehicle.types'; import type { VehicleStats, InspectionAlert } from '../../types/vehicle.types';
@@ -22,39 +23,22 @@ interface VehicleDashboardCardProps {
const VehicleDashboardCard: React.FC<VehicleDashboardCardProps> = ({ const VehicleDashboardCard: React.FC<VehicleDashboardCardProps> = ({
hideWhenEmpty = false, hideWhenEmpty = false,
}) => { }) => {
const [stats, setStats] = useState<VehicleStats | null>(null); const { data: stats, isLoading: statsLoading, isError: statsError } = useQuery<VehicleStats>({
const [alerts, setAlerts] = useState<InspectionAlert[]>([]); queryKey: ['vehicle-stats'],
const [equipmentWarnings, setEquipmentWarnings] = useState<VehicleEquipmentWarning[]>([]); queryFn: () => vehiclesApi.getStats(),
const [loading, setLoading] = useState(true); });
const [error, setError] = useState<string | null>(null);
useEffect(() => { const { data: alerts = [], isLoading: alertsLoading } = useQuery<InspectionAlert[]>({
let mounted = true; queryKey: ['vehicle-alerts'],
const fetchData = async () => { queryFn: () => vehiclesApi.getAlerts(30),
try { });
setLoading(true);
setError(null); const { data: equipmentWarnings = [], isLoading: warningsLoading } = useQuery<VehicleEquipmentWarning[]>({
const [statsData, alertsData, warningsData] = await Promise.all([ queryKey: ['vehicle-equipment-warnings'],
vehiclesApi.getStats(), queryFn: () => equipmentApi.getVehicleWarnings(),
vehiclesApi.getAlerts(30), });
equipmentApi.getVehicleWarnings(),
]); const loading = statsLoading || alertsLoading || warningsLoading;
if (mounted) {
setStats(statsData);
setAlerts(alertsData);
setEquipmentWarnings(warningsData);
}
} catch {
if (mounted) setError('Fahrzeugstatus konnte nicht geladen werden.');
} finally {
if (mounted) setLoading(false);
}
};
fetchData();
return () => {
mounted = false;
};
}, []);
if (loading) { if (loading) {
return ( return (
@@ -69,12 +53,12 @@ const VehicleDashboardCard: React.FC<VehicleDashboardCardProps> = ({
); );
} }
if (error) { if (statsError) {
return ( return (
<Card> <Card>
<CardContent> <CardContent>
<Typography variant="body2" color="error"> <Typography variant="body2" color="error">
{error} Fahrzeugstatus konnte nicht geladen werden.
</Typography> </Typography>
</CardContent> </CardContent>
</Card> </Card>
@@ -94,7 +78,6 @@ const VehicleDashboardCard: React.FC<VehicleDashboardCardProps> = ({
const allGood = stats.einsatzbereit === stats.total && !hasConcerns; const allGood = stats.einsatzbereit === stats.total && !hasConcerns;
// If hideWhenEmpty and everything is fine, render nothing
if (hideWhenEmpty && allGood) return null; if (hideWhenEmpty && allGood) return null;
return ( return (

View File

@@ -10,7 +10,9 @@ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // keep cache 10 minutes
retry: 1, retry: 1,
refetchOnWindowFocus: false, // prevent refetch on every tab switch
}, },
}, },
}); });

View File

@@ -32,7 +32,7 @@ function Dashboard() {
return ( return (
<DashboardLayout> <DashboardLayout>
<Container maxWidth="xl"> <Container maxWidth={false} disableGutters>
<Grid container spacing={3}> <Grid container spacing={3}>
{/* Welcome Message */} {/* Welcome Message */}
<Grid item xs={12}> <Grid item xs={12}>