adding chat features, admin features and bug fixes

This commit is contained in:
Matthias Hochmeister
2026-03-12 08:16:34 +01:00
parent 7b14e3d5ba
commit 31f1414e06
43 changed files with 2610 additions and 16 deletions

View File

@@ -0,0 +1,131 @@
import { useState } from 'react';
import {
Box,
TextField,
Button,
MenuItem,
Typography,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
CircularProgress,
} from '@mui/material';
import SendIcon from '@mui/icons-material/Send';
import { useMutation } from '@tanstack/react-query';
import { adminApi } from '../../services/admin';
import { useNotification } from '../../contexts/NotificationContext';
import type { BroadcastPayload } from '../../types/admin.types';
function NotificationBroadcastTab() {
const { showSuccess, showError } = useNotification();
const [titel, setTitel] = useState('');
const [nachricht, setNachricht] = useState('');
const [schwere, setSchwere] = useState<'info' | 'warnung' | 'fehler'>('info');
const [targetGroup, setTargetGroup] = useState('');
const [confirmOpen, setConfirmOpen] = useState(false);
const broadcastMutation = useMutation({
mutationFn: (data: BroadcastPayload) => adminApi.broadcast(data),
onSuccess: (result) => {
showSuccess(`Benachrichtigung an ${result.sent} Benutzer gesendet`);
setTitel('');
setNachricht('');
setSchwere('info');
setTargetGroup('');
},
onError: () => {
showError('Fehler beim Senden der Benachrichtigung');
},
});
const handleSubmit = () => {
setConfirmOpen(true);
};
const handleConfirm = () => {
setConfirmOpen(false);
broadcastMutation.mutate({
titel,
nachricht,
schwere,
...(targetGroup.trim() ? { targetGroup: targetGroup.trim() } : {}),
});
};
return (
<Box sx={{ maxWidth: 600 }}>
<Typography variant="h6" sx={{ mb: 2 }}>Benachrichtigung senden</Typography>
<TextField
label="Titel"
fullWidth
value={titel}
onChange={(e) => setTitel(e.target.value)}
sx={{ mb: 2 }}
inputProps={{ maxLength: 200 }}
/>
<TextField
label="Nachricht"
fullWidth
multiline
rows={4}
value={nachricht}
onChange={(e) => setNachricht(e.target.value)}
sx={{ mb: 2 }}
inputProps={{ maxLength: 2000 }}
/>
<TextField
select
label="Schwere"
fullWidth
value={schwere}
onChange={(e) => setSchwere(e.target.value as 'info' | 'warnung' | 'fehler')}
sx={{ mb: 2 }}
>
<MenuItem value="info">Info</MenuItem>
<MenuItem value="warnung">Warnung</MenuItem>
<MenuItem value="fehler">Fehler</MenuItem>
</TextField>
<TextField
label="Zielgruppe (optional)"
fullWidth
value={targetGroup}
onChange={(e) => setTargetGroup(e.target.value)}
helperText="Leer lassen um an alle aktiven Benutzer zu senden"
sx={{ mb: 3 }}
/>
<Button
variant="contained"
startIcon={broadcastMutation.isPending ? <CircularProgress size={18} color="inherit" /> : <SendIcon />}
onClick={handleSubmit}
disabled={!titel.trim() || !nachricht.trim() || broadcastMutation.isPending}
>
Senden
</Button>
<Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)}>
<DialogTitle>Benachrichtigung senden?</DialogTitle>
<DialogContent>
<DialogContentText>
Sind Sie sicher, dass Sie diese Benachrichtigung
{targetGroup.trim() ? ` an die Gruppe "${targetGroup.trim()}"` : ' an alle aktiven Benutzer'} senden moechten?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmOpen(false)}>Abbrechen</Button>
<Button onClick={handleConfirm} variant="contained" color="primary">
Bestaetigen
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
export default NotificationBroadcastTab;

View File

@@ -0,0 +1,205 @@
import { useState } from 'react';
import {
Box,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
IconButton,
Typography,
CircularProgress,
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminApi } from '../../services/admin';
import type { PingResult } from '../../types/admin.types';
function ServiceManagerTab() {
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
const [newName, setNewName] = useState('');
const [newUrl, setNewUrl] = useState('');
const { data: services, isLoading: servicesLoading } = useQuery({
queryKey: ['admin', 'services'],
queryFn: adminApi.getServices,
refetchInterval: 15000,
});
const { data: pingResults, isLoading: pingLoading } = useQuery({
queryKey: ['admin', 'services', 'ping'],
queryFn: adminApi.pingAll,
refetchInterval: 15000,
});
const createMutation = useMutation({
mutationFn: (data: { name: string; url: string }) => adminApi.createService(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'services'] });
setDialogOpen(false);
setNewName('');
setNewUrl('');
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => adminApi.deleteService(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'services'] });
},
});
const getPingForUrl = (url: string): PingResult | undefined => {
return pingResults?.find((p) => p.url === url);
};
const handleCreate = () => {
if (newName.trim() && newUrl.trim()) {
createMutation.mutate({ name: newName.trim(), url: newUrl.trim() });
}
};
if (servicesLoading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
}
const allItems = [
...(services ?? []).map((s) => ({
id: s.id,
name: s.name,
url: s.url,
type: s.type,
isCustom: s.type === 'custom',
})),
];
// Also include internal services from ping results that aren't in the DB
if (pingResults) {
for (const pr of pingResults) {
if (!allItems.find((item) => item.url === pr.url)) {
allItems.push({
id: pr.url,
name: pr.name,
url: pr.url,
type: 'internal' as const,
isCustom: false,
});
}
}
}
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Service Monitor</Typography>
<Button startIcon={<AddIcon />} variant="contained" onClick={() => setDialogOpen(true)}>
Service hinzufuegen
</Button>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Status</TableCell>
<TableCell>Name</TableCell>
<TableCell>URL</TableCell>
<TableCell>Typ</TableCell>
<TableCell>Latenz</TableCell>
<TableCell>Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{allItems.map((item) => {
const ping = getPingForUrl(item.url);
return (
<TableRow key={item.id}>
<TableCell>
{pingLoading ? (
<CircularProgress size={16} />
) : (
<Box
sx={{
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: ping?.status === 'up' ? 'success.main' : ping ? 'error.main' : 'grey.400',
}}
/>
)}
</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell sx={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{item.url}
</TableCell>
<TableCell>{item.type}</TableCell>
<TableCell>{ping ? `${ping.latencyMs}ms` : '-'}</TableCell>
<TableCell>
{item.isCustom && (
<IconButton
size="small"
color="error"
onClick={() => deleteMutation.mutate(item.id)}
disabled={deleteMutation.isPending}
>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</TableCell>
</TableRow>
);
})}
{allItems.length === 0 && (
<TableRow>
<TableCell colSpan={6} align="center">Keine Services konfiguriert</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Neuen Service hinzufuegen</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Name"
fullWidth
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<TextField
margin="dense"
label="URL"
fullWidth
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
<Button
onClick={handleCreate}
variant="contained"
disabled={createMutation.isPending || !newName.trim() || !newUrl.trim()}
>
Erstellen
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
export default ServiceManagerTab;

View File

@@ -0,0 +1,117 @@
import { Box, Card, CardContent, Typography, Chip, LinearProgress, CircularProgress, Grid } from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import { adminApi } from '../../services/admin';
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
parts.push(`${mins}m`);
return parts.join(' ');
}
function formatBytes(bytes: number): string {
const mb = bytes / (1024 * 1024);
return `${mb.toFixed(1)} MB`;
}
function SystemHealthTab() {
const { data: health, isLoading } = useQuery({
queryKey: ['admin', 'system', 'health'],
queryFn: adminApi.getSystemHealth,
refetchInterval: 30000,
});
if (isLoading || !health) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
}
const heapPercent = (health.memoryUsage.heapUsed / health.memoryUsage.heapTotal) * 100;
return (
<Box>
<Typography variant="h6" sx={{ mb: 2 }}>Systemstatus</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6} md={4}>
<Card>
<CardContent>
<Typography color="text.secondary" gutterBottom>Node.js Version</Typography>
<Chip label={health.nodeVersion} color="primary" />
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<Card>
<CardContent>
<Typography color="text.secondary" gutterBottom>Uptime</Typography>
<Typography variant="h5">{formatUptime(health.uptime)}</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<Card>
<CardContent>
<Typography color="text.secondary" gutterBottom>Datenbank</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: health.dbStatus ? 'success.main' : 'error.main',
}}
/>
<Typography>{health.dbStatus ? 'Verbunden' : 'Nicht erreichbar'}</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Groesse: {formatBytes(Number(health.dbSize))}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<Card>
<CardContent>
<Typography color="text.secondary" gutterBottom>Heap Speicher</Typography>
<Typography variant="body2">
{formatBytes(health.memoryUsage.heapUsed)} / {formatBytes(health.memoryUsage.heapTotal)}
</Typography>
<LinearProgress
variant="determinate"
value={heapPercent}
sx={{ mt: 1, height: 8, borderRadius: 4 }}
color={heapPercent > 85 ? 'error' : heapPercent > 70 ? 'warning' : 'primary'}
/>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<Card>
<CardContent>
<Typography color="text.secondary" gutterBottom>RSS Speicher</Typography>
<Typography variant="h5">{formatBytes(health.memoryUsage.rss)}</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<Card>
<CardContent>
<Typography color="text.secondary" gutterBottom>External Speicher</Typography>
<Typography variant="h5">{formatBytes(health.memoryUsage.external)}</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
);
}
export default SystemHealthTab;

View File

@@ -0,0 +1,166 @@
import { useState, useMemo } from 'react';
import {
Box,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TableSortLabel,
Paper,
TextField,
Chip,
Typography,
CircularProgress,
} from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import { adminApi } from '../../services/admin';
import type { UserOverview } from '../../types/admin.types';
type SortKey = 'name' | 'email' | 'role' | 'is_active' | 'last_login_at';
type SortDir = 'asc' | 'desc';
function formatRelativeTime(dateStr: string | null): string {
if (!dateStr) return 'Nie';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'Gerade eben';
if (diffMins < 60) return `vor ${diffMins}m`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `vor ${diffHours}h`;
const diffDays = Math.floor(diffHours / 24);
return `vor ${diffDays}d`;
}
function UserOverviewTab() {
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('name');
const [sortDir, setSortDir] = useState<SortDir>('asc');
const { data: users, isLoading } = useQuery({
queryKey: ['admin', 'users'],
queryFn: adminApi.getUsers,
});
const handleSort = (key: SortKey) => {
if (sortKey === key) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(key);
setSortDir('asc');
}
};
const filtered = useMemo(() => {
if (!users) return [];
const q = search.toLowerCase();
let result = users.filter(
(u) => u.name?.toLowerCase().includes(q) || u.email?.toLowerCase().includes(q)
);
result.sort((a, b) => {
const valA = a[sortKey];
const valB = b[sortKey];
let cmp = 0;
if (valA == null && valB == null) cmp = 0;
else if (valA == null) cmp = -1;
else if (valB == null) cmp = 1;
else if (typeof valA === 'string' && typeof valB === 'string') {
cmp = valA.localeCompare(valB);
} else if (typeof valA === 'boolean' && typeof valB === 'boolean') {
cmp = valA === valB ? 0 : valA ? 1 : -1;
}
return sortDir === 'asc' ? cmp : -cmp;
});
return result;
}, [users, search, sortKey, sortDir]);
if (isLoading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
}
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Benutzer ({filtered.length})</Typography>
<TextField
size="small"
placeholder="Suche nach Name oder E-Mail..."
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ minWidth: 280 }}
/>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>
<TableSortLabel active={sortKey === 'name'} direction={sortKey === 'name' ? sortDir : 'asc'} onClick={() => handleSort('name')}>
Name
</TableSortLabel>
</TableCell>
<TableCell>
<TableSortLabel active={sortKey === 'email'} direction={sortKey === 'email' ? sortDir : 'asc'} onClick={() => handleSort('email')}>
E-Mail
</TableSortLabel>
</TableCell>
<TableCell>
<TableSortLabel active={sortKey === 'role'} direction={sortKey === 'role' ? sortDir : 'asc'} onClick={() => handleSort('role')}>
Rolle
</TableSortLabel>
</TableCell>
<TableCell>Gruppen</TableCell>
<TableCell>
<TableSortLabel active={sortKey === 'is_active'} direction={sortKey === 'is_active' ? sortDir : 'asc'} onClick={() => handleSort('is_active')}>
Status
</TableSortLabel>
</TableCell>
<TableCell>
<TableSortLabel active={sortKey === 'last_login_at'} direction={sortKey === 'last_login_at' ? sortDir : 'asc'} onClick={() => handleSort('last_login_at')}>
Letzter Login
</TableSortLabel>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filtered.map((user: UserOverview) => (
<TableRow key={user.id}>
<TableCell>{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<Chip
label={user.role}
size="small"
color={user.role === 'admin' ? 'error' : 'default'}
/>
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{(user.groups ?? []).map((g) => (
<Chip key={g} label={g} size="small" variant="outlined" />
))}
</Box>
</TableCell>
<TableCell>
<Chip
label={user.is_active ? 'Aktiv' : 'Inaktiv'}
size="small"
color={user.is_active ? 'success' : 'default'}
/>
</TableCell>
<TableCell>{formatRelativeTime(user.last_login_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
);
}
export default UserOverviewTab;