adding chat features, admin features and bug fixes
This commit is contained in:
@@ -83,6 +83,8 @@ import bookingRoutes from './routes/booking.routes';
|
||||
import notificationRoutes from './routes/notification.routes';
|
||||
import bookstackRoutes from './routes/bookstack.routes';
|
||||
import vikunjaRoutes from './routes/vikunja.routes';
|
||||
import configRoutes from './routes/config.routes';
|
||||
import serviceMonitorRoutes from './routes/serviceMonitor.routes';
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/user', userRoutes);
|
||||
@@ -99,6 +101,8 @@ app.use('/api/bookings', bookingRoutes);
|
||||
app.use('/api/notifications', notificationRoutes);
|
||||
app.use('/api/bookstack', bookstackRoutes);
|
||||
app.use('/api/vikunja', vikunjaRoutes);
|
||||
app.use('/api/config', configRoutes);
|
||||
app.use('/api/admin', serviceMonitorRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.use(notFoundHandler);
|
||||
|
||||
@@ -40,6 +40,24 @@ class BookStackController {
|
||||
res.status(500).json({ success: false, message: 'BookStack-Suche fehlgeschlagen' });
|
||||
}
|
||||
}
|
||||
async getPage(req: Request, res: Response): Promise<void> {
|
||||
if (!environment.bookstack.url) {
|
||||
res.status(200).json({ success: true, data: null, configured: false });
|
||||
return;
|
||||
}
|
||||
const id = parseInt(String(req.params.id), 10);
|
||||
if (isNaN(id) || id <= 0) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige Seiten-ID' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const page = await bookstackService.getPageById(id);
|
||||
res.status(200).json({ success: true, data: page, configured: true });
|
||||
} catch (error) {
|
||||
logger.error('BookStackController.getPage error', { error });
|
||||
res.status(500).json({ success: false, message: 'BookStack-Seite konnte nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new BookStackController();
|
||||
|
||||
14
backend/src/controllers/config.controller.ts
Normal file
14
backend/src/controllers/config.controller.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Request, Response } from 'express';
|
||||
import environment from '../config/environment';
|
||||
|
||||
class ConfigController {
|
||||
async getExternalLinks(_req: Request, res: Response): Promise<void> {
|
||||
const links: Record<string, string> = {};
|
||||
if (environment.nextcloudUrl) links.nextcloud = environment.nextcloudUrl;
|
||||
if (environment.bookstack.url) links.bookstack = environment.bookstack.url;
|
||||
if (environment.vikunja.url) links.vikunja = environment.vikunja.url;
|
||||
res.status(200).json({ success: true, data: links });
|
||||
}
|
||||
}
|
||||
|
||||
export default new ConfigController();
|
||||
@@ -80,6 +80,91 @@ class NextcloudController {
|
||||
res.status(500).json({ success: false, message: 'Nextcloud-Trennung fehlgeschlagen' });
|
||||
}
|
||||
}
|
||||
|
||||
async getRooms(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||
if (!credentials) {
|
||||
res.status(200).json({ success: true, data: { connected: false, rooms: [] } });
|
||||
return;
|
||||
}
|
||||
const rooms = await nextcloudService.getAllConversations(credentials.loginName, credentials.appPassword);
|
||||
res.status(200).json({ success: true, data: { connected: true, rooms, loginName: credentials.loginName } });
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||
await userService.clearNextcloudCredentials(req.user!.id);
|
||||
res.status(200).json({ success: true, data: { connected: false, rooms: [] } });
|
||||
return;
|
||||
}
|
||||
logger.error('getRooms error', { error });
|
||||
res.status(500).json({ success: false, message: 'Nextcloud-Räume konnten nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async getMessages(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||
if (!credentials) {
|
||||
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||
return;
|
||||
}
|
||||
const token = req.params.token as string;
|
||||
if (!token) {
|
||||
res.status(400).json({ success: false, message: 'Room token fehlt' });
|
||||
return;
|
||||
}
|
||||
const messages = await nextcloudService.getMessages(token, credentials.loginName, credentials.appPassword);
|
||||
res.status(200).json({ success: true, data: messages });
|
||||
} catch (error) {
|
||||
logger.error('getMessages error', { error });
|
||||
res.status(500).json({ success: false, message: 'Nachrichten konnten nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||
if (!credentials) {
|
||||
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||
return;
|
||||
}
|
||||
const token = req.params.token as string;
|
||||
const { message } = req.body;
|
||||
if (!token || !message || typeof message !== 'string' || message.trim().length === 0) {
|
||||
res.status(400).json({ success: false, message: 'Token und Nachricht erforderlich' });
|
||||
return;
|
||||
}
|
||||
if (message.length > 32000) {
|
||||
res.status(400).json({ success: false, message: 'Nachricht zu lang' });
|
||||
return;
|
||||
}
|
||||
await nextcloudService.sendMessage(token, message.trim(), credentials.loginName, credentials.appPassword);
|
||||
res.status(200).json({ success: true, data: null });
|
||||
} catch (error) {
|
||||
logger.error('sendMessage error', { error });
|
||||
res.status(500).json({ success: false, message: 'Nachricht konnte nicht gesendet werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async markRoomAsRead(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||
if (!credentials) {
|
||||
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||
return;
|
||||
}
|
||||
const token = req.params.token as string;
|
||||
if (!token) {
|
||||
res.status(400).json({ success: false, message: 'Room token fehlt' });
|
||||
return;
|
||||
}
|
||||
await nextcloudService.markAsRead(token, credentials.loginName, credentials.appPassword);
|
||||
res.status(200).json({ success: true, data: null });
|
||||
} catch (error) {
|
||||
logger.error('markRoomAsRead error', { error });
|
||||
res.status(500).json({ success: false, message: 'Raum konnte nicht als gelesen markiert werden' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new NextcloudController();
|
||||
|
||||
192
backend/src/controllers/serviceMonitor.controller.ts
Normal file
192
backend/src/controllers/serviceMonitor.controller.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import serviceMonitorService from '../services/serviceMonitor.service';
|
||||
import notificationService from '../services/notification.service';
|
||||
import pool from '../config/database';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const createServiceSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
url: z.string().url().max(500),
|
||||
});
|
||||
|
||||
const updateServiceSchema = z.object({
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
url: z.string().url().max(500).optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const broadcastSchema = z.object({
|
||||
titel: z.string().min(1).max(200),
|
||||
nachricht: z.string().min(1).max(2000),
|
||||
schwere: z.enum(['info', 'warnung', 'fehler']).default('info'),
|
||||
targetGroup: z.string().optional(),
|
||||
});
|
||||
|
||||
class ServiceMonitorController {
|
||||
async getAll(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const services = await serviceMonitorService.getAllServices();
|
||||
res.json({ success: true, data: services });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get services', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to get services' });
|
||||
}
|
||||
}
|
||||
|
||||
async create(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { name, url } = createServiceSchema.parse(req.body);
|
||||
const service = await serviceMonitorService.createService(name, url);
|
||||
res.status(201).json({ success: true, data: service });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues });
|
||||
return;
|
||||
}
|
||||
logger.error('Failed to create service', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to create service' });
|
||||
}
|
||||
}
|
||||
|
||||
async update(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const data = updateServiceSchema.parse(req.body);
|
||||
const service = await serviceMonitorService.updateService(req.params.id as string, data);
|
||||
res.json({ success: true, data: service });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues });
|
||||
return;
|
||||
}
|
||||
logger.error('Failed to update service', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to update service' });
|
||||
}
|
||||
}
|
||||
|
||||
async delete(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const deleted = await serviceMonitorService.deleteService(req.params.id as string);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ success: false, message: 'Service not found or is internal' });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete service', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to delete service' });
|
||||
}
|
||||
}
|
||||
|
||||
async pingAll(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const results = await serviceMonitorService.pingAll();
|
||||
res.json({ success: true, data: results });
|
||||
} catch (error) {
|
||||
logger.error('Failed to ping services', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to ping services' });
|
||||
}
|
||||
}
|
||||
|
||||
async getStatusSummary(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const summary = await serviceMonitorService.getStatusSummary();
|
||||
res.json({ success: true, data: summary });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get status summary', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to get status summary' });
|
||||
}
|
||||
}
|
||||
|
||||
async getSystemHealth(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
let dbStatus = false;
|
||||
let dbSize = '0';
|
||||
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
dbStatus = true;
|
||||
const sizeResult = await pool.query('SELECT pg_database_size(current_database()) as size');
|
||||
dbSize = sizeResult.rows[0].size;
|
||||
} catch {
|
||||
// DB is down
|
||||
}
|
||||
|
||||
const mem = process.memoryUsage();
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
nodeVersion: process.version,
|
||||
uptime: process.uptime(),
|
||||
memoryUsage: {
|
||||
heapUsed: mem.heapUsed,
|
||||
heapTotal: mem.heapTotal,
|
||||
rss: mem.rss,
|
||||
external: mem.external,
|
||||
},
|
||||
dbStatus,
|
||||
dbSize,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get system health', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to get system health' });
|
||||
}
|
||||
}
|
||||
|
||||
async getUsers(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT id, email, name, role, authentik_groups as groups, is_active, last_login_at
|
||||
FROM users ORDER BY name`
|
||||
);
|
||||
res.json({ success: true, data: result.rows });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get users', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to get users' });
|
||||
}
|
||||
}
|
||||
|
||||
async broadcastNotification(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { titel, nachricht, schwere, targetGroup } = broadcastSchema.parse(req.body);
|
||||
|
||||
let users;
|
||||
if (targetGroup) {
|
||||
const result = await pool.query(
|
||||
`SELECT id FROM users WHERE is_active = TRUE AND $1 = ANY(authentik_groups)`,
|
||||
[targetGroup]
|
||||
);
|
||||
users = result.rows;
|
||||
} else {
|
||||
const result = await pool.query(
|
||||
`SELECT id FROM users WHERE is_active = TRUE`
|
||||
);
|
||||
users = result.rows;
|
||||
}
|
||||
|
||||
let sent = 0;
|
||||
for (const user of users) {
|
||||
await notificationService.createNotification({
|
||||
user_id: user.id,
|
||||
typ: 'broadcast',
|
||||
titel,
|
||||
nachricht,
|
||||
schwere,
|
||||
});
|
||||
sent++;
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { sent } });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues });
|
||||
return;
|
||||
}
|
||||
logger.error('Failed to broadcast notification', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to broadcast notification' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ServiceMonitorController();
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS monitored_services (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(200) NOT NULL,
|
||||
url VARCHAR(500) NOT NULL,
|
||||
type VARCHAR(20) NOT NULL DEFAULT 'custom' CHECK (type IN ('internal','custom')),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE OR REPLACE TRIGGER update_monitored_services_updated_at
|
||||
BEFORE UPDATE ON monitored_services
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
@@ -6,5 +6,6 @@ const router = Router();
|
||||
|
||||
router.get('/recent', authenticate, bookstackController.getRecent.bind(bookstackController));
|
||||
router.get('/search', authenticate, bookstackController.search.bind(bookstackController));
|
||||
router.get('/pages/:id', authenticate, bookstackController.getPage.bind(bookstackController));
|
||||
|
||||
export default router;
|
||||
|
||||
8
backend/src/routes/config.routes.ts
Normal file
8
backend/src/routes/config.routes.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import configController from '../controllers/config.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
router.get('/external-links', authenticate, configController.getExternalLinks.bind(configController));
|
||||
|
||||
export default router;
|
||||
@@ -9,4 +9,9 @@ router.post('/connect', authenticate, nextcloudController.initiateConnect.bind(n
|
||||
router.post('/poll', authenticate, nextcloudController.pollConnect.bind(nextcloudController));
|
||||
router.delete('/connect', authenticate, nextcloudController.disconnect.bind(nextcloudController));
|
||||
|
||||
router.get('/rooms', authenticate, nextcloudController.getRooms.bind(nextcloudController));
|
||||
router.get('/rooms/:token/messages', authenticate, nextcloudController.getMessages.bind(nextcloudController));
|
||||
router.post('/rooms/:token/messages', authenticate, nextcloudController.sendMessage.bind(nextcloudController));
|
||||
router.post('/rooms/:token/read', authenticate, nextcloudController.markRoomAsRead.bind(nextcloudController));
|
||||
|
||||
export default router;
|
||||
|
||||
20
backend/src/routes/serviceMonitor.routes.ts
Normal file
20
backend/src/routes/serviceMonitor.routes.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Router } from 'express';
|
||||
import serviceMonitorController from '../controllers/serviceMonitor.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import { requirePermission } from '../middleware/rbac.middleware';
|
||||
|
||||
const router = Router();
|
||||
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', ...auth, serviceMonitorController.getAll.bind(serviceMonitorController));
|
||||
router.post('/services', ...auth, serviceMonitorController.create.bind(serviceMonitorController));
|
||||
router.put('/services/:id', ...auth, serviceMonitorController.update.bind(serviceMonitorController));
|
||||
router.delete('/services/:id', ...auth, serviceMonitorController.delete.bind(serviceMonitorController));
|
||||
router.get('/system/health', ...auth, serviceMonitorController.getSystemHealth.bind(serviceMonitorController));
|
||||
router.get('/users', ...auth, serviceMonitorController.getUsers.bind(serviceMonitorController));
|
||||
router.post('/notifications/broadcast', ...auth, serviceMonitorController.broadcastNotification.bind(serviceMonitorController));
|
||||
|
||||
export default router;
|
||||
@@ -151,4 +151,59 @@ async function searchPages(query: string): Promise<BookStackSearchResult[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export default { getRecentPages, searchPages };
|
||||
export interface BookStackPageDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
book_id: number;
|
||||
book_slug: string;
|
||||
chapter_id: number;
|
||||
html: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
url: string;
|
||||
book?: { name: string };
|
||||
createdBy?: { name: string };
|
||||
updatedBy?: { name: string };
|
||||
}
|
||||
|
||||
async function getPageById(id: number): Promise<BookStackPageDetail> {
|
||||
const { bookstack } = environment;
|
||||
if (!bookstack.url || !isValidServiceUrl(bookstack.url)) {
|
||||
throw new Error('BOOKSTACK_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${bookstack.url}/api/pages/${id}`,
|
||||
{ headers: buildHeaders() },
|
||||
);
|
||||
const page = response.data;
|
||||
return {
|
||||
id: page.id,
|
||||
name: page.name,
|
||||
slug: page.slug,
|
||||
book_id: page.book_id,
|
||||
book_slug: page.book_slug ?? '',
|
||||
chapter_id: page.chapter_id ?? 0,
|
||||
html: page.html ?? '',
|
||||
created_at: page.created_at,
|
||||
updated_at: page.updated_at,
|
||||
url: `${bookstack.url}/books/${page.book_slug}/page/${page.slug}`,
|
||||
book: page.book,
|
||||
createdBy: page.created_by,
|
||||
updatedBy: page.updated_by,
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
logger.error('BookStack getPageById failed', {
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
});
|
||||
}
|
||||
logger.error('BookStackService.getPageById failed', { error });
|
||||
throw new Error('Failed to fetch BookStack page');
|
||||
}
|
||||
}
|
||||
|
||||
export default { getRecentPages, searchPages, getPageById };
|
||||
|
||||
@@ -127,6 +127,143 @@ async function pollLoginFlow(pollEndpoint: string, pollToken: string): Promise<L
|
||||
}
|
||||
}
|
||||
|
||||
interface NextcloudChatMessage {
|
||||
id: number;
|
||||
token: string;
|
||||
actorType: string;
|
||||
actorId: string;
|
||||
actorDisplayName: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
messageType: string;
|
||||
systemMessage: string;
|
||||
}
|
||||
|
||||
async function getAllConversations(loginName: string, appPassword: string): Promise<NextcloudConversation[]> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/room?format=json`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
|
||||
'OCS-APIRequest': 'true',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const rooms: any[] = response.data?.ocs?.data ?? [];
|
||||
return rooms
|
||||
.filter((r: any) => r.type !== 4)
|
||||
.sort((a: any, b: any) => (b.lastActivity ?? 0) - (a.lastActivity ?? 0))
|
||||
.map((r: any) => ({
|
||||
token: r.token,
|
||||
displayName: r.displayName,
|
||||
unreadMessages: r.unreadMessages ?? 0,
|
||||
lastActivity: r.lastActivity ?? 0,
|
||||
lastMessage: r.lastMessage
|
||||
? {
|
||||
text: r.lastMessage.message ?? '',
|
||||
author: r.lastMessage.actorDisplayName ?? '',
|
||||
timestamp: r.lastMessage.timestamp ?? 0,
|
||||
}
|
||||
: null,
|
||||
type: r.type,
|
||||
url: `${baseUrl}/call/${r.token}`,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
const err = new Error('Nextcloud app password is invalid');
|
||||
(err as any).code = 'NEXTCLOUD_AUTH_INVALID';
|
||||
throw err;
|
||||
}
|
||||
if (axios.isAxiosError(error)) {
|
||||
logger.error('Nextcloud getAllConversations failed', {
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
});
|
||||
throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`);
|
||||
}
|
||||
logger.error('NextcloudService.getAllConversations failed', { error });
|
||||
throw new Error('Failed to fetch Nextcloud conversations');
|
||||
}
|
||||
}
|
||||
|
||||
async function getMessages(token: string, loginName: string, appPassword: string): Promise<NextcloudChatMessage[]> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
const response = await axios.get(
|
||||
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}`,
|
||||
{
|
||||
params: { lookIntoFuture: 0, limit: 50, setReadMarker: 0 },
|
||||
headers: {
|
||||
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
|
||||
'OCS-APIRequest': 'true',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const messages: any[] = response.data?.ocs?.data ?? [];
|
||||
return messages.map((m: any) => ({
|
||||
id: m.id,
|
||||
token: m.token,
|
||||
actorType: m.actorType,
|
||||
actorId: m.actorId,
|
||||
actorDisplayName: m.actorDisplayName,
|
||||
message: m.message,
|
||||
timestamp: m.timestamp,
|
||||
messageType: m.messageType ?? '',
|
||||
systemMessage: m.systemMessage ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
async function sendMessage(token: string, message: string, loginName: string, appPassword: string): Promise<void> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
await axios.post(
|
||||
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}`,
|
||||
{ message },
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
|
||||
'OCS-APIRequest': 'true',
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function markAsRead(token: string, loginName: string, appPassword: string): Promise<void> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
await axios.delete(
|
||||
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}/read`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
|
||||
'OCS-APIRequest': 'true',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function getConversations(loginName: string, appPassword: string): Promise<ConversationsResult> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
@@ -193,4 +330,5 @@ async function getConversations(loginName: string, appPassword: string): Promise
|
||||
}
|
||||
}
|
||||
|
||||
export default { initiateLoginFlow, pollLoginFlow, getConversations };
|
||||
export type { NextcloudChatMessage };
|
||||
export default { initiateLoginFlow, pollLoginFlow, getConversations, getAllConversations, getMessages, sendMessage, markAsRead };
|
||||
|
||||
193
backend/src/services/serviceMonitor.service.ts
Normal file
193
backend/src/services/serviceMonitor.service.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import axios from 'axios';
|
||||
import pool from '../config/database';
|
||||
import environment from '../config/environment';
|
||||
|
||||
export interface MonitoredService {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
type: 'internal' | 'custom';
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PingResult {
|
||||
name: string;
|
||||
url: string;
|
||||
status: 'up' | 'down';
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StatusSummary {
|
||||
up: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
class ServiceMonitorService {
|
||||
async getAllServices(): Promise<MonitoredService[]> {
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM monitored_services ORDER BY created_at'
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async createService(name: string, url: string, type: string = 'custom'): Promise<MonitoredService> {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO monitored_services (name, url, type) VALUES ($1, $2, $3) RETURNING *`,
|
||||
[name, url, type]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async updateService(id: string, data: Partial<Pick<MonitoredService, 'name' | 'url' | 'is_active'>>): Promise<MonitoredService> {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (data.name !== undefined) {
|
||||
fields.push(`name = $${idx++}`);
|
||||
values.push(data.name);
|
||||
}
|
||||
if (data.url !== undefined) {
|
||||
fields.push(`url = $${idx++}`);
|
||||
values.push(data.url);
|
||||
}
|
||||
if (data.is_active !== undefined) {
|
||||
fields.push(`is_active = $${idx++}`);
|
||||
values.push(data.is_active);
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
throw new Error('No fields to update');
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
const result = await pool.query(
|
||||
`UPDATE monitored_services SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
throw new Error('Service not found');
|
||||
}
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async deleteService(id: string): Promise<boolean> {
|
||||
const result = await pool.query(
|
||||
`DELETE FROM monitored_services WHERE id = $1 AND type = 'custom'`,
|
||||
[id]
|
||||
);
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
async pingService(url: string, headers?: Record<string, string>): Promise<PingResult> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await axios.get(url, { timeout: 5000, headers });
|
||||
return {
|
||||
name: '',
|
||||
url,
|
||||
status: 'up',
|
||||
latencyMs: Date.now() - start,
|
||||
};
|
||||
} catch (error) {
|
||||
// Treat any HTTP response (even 4xx) as "service is reachable" only for status.php-type endpoints
|
||||
// For API endpoints requiring auth, a 401/403 still means the service is up
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
const status = error.response.status;
|
||||
// If we got a response, the service is running (auth errors = service is up)
|
||||
if (headers && (status === 401 || status === 403)) {
|
||||
return {
|
||||
name: '',
|
||||
url,
|
||||
status: 'up',
|
||||
latencyMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: '',
|
||||
url,
|
||||
status: 'down',
|
||||
latencyMs: Date.now() - start,
|
||||
error: axios.isAxiosError(error)
|
||||
? `${error.code ?? 'ERROR'}: ${error.message}`
|
||||
: String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async pingAll(): Promise<PingResult[]> {
|
||||
const services = await this.getAllServices();
|
||||
const activeServices = services.filter((s) => s.is_active);
|
||||
|
||||
const internalServices = this.getInternalServices();
|
||||
|
||||
const allTargets = [
|
||||
...activeServices.map((s) => ({ name: s.name, url: s.url, pingUrl: s.url, headers: undefined as Record<string, string> | undefined })),
|
||||
...internalServices,
|
||||
];
|
||||
|
||||
const results = await Promise.all(
|
||||
allTargets.map(async (target) => {
|
||||
const result = await this.pingService(target.pingUrl, target.headers);
|
||||
result.name = target.name;
|
||||
result.url = target.url;
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async getStatusSummary(): Promise<StatusSummary> {
|
||||
const results = await this.pingAll();
|
||||
return {
|
||||
up: results.filter((r) => r.status === 'up').length,
|
||||
total: results.length,
|
||||
};
|
||||
}
|
||||
|
||||
private getInternalServices(): Array<{ name: string; url: string; pingUrl: string; headers?: Record<string, string> }> {
|
||||
const internal: Array<{ name: string; url: string; pingUrl: string; headers?: Record<string, string> }> = [];
|
||||
|
||||
const { bookstack, vikunja, nextcloudUrl } = environment;
|
||||
|
||||
if (nextcloudUrl) {
|
||||
internal.push({
|
||||
name: 'Nextcloud',
|
||||
url: nextcloudUrl,
|
||||
pingUrl: `${nextcloudUrl}/status.php`,
|
||||
});
|
||||
}
|
||||
|
||||
if (bookstack.url) {
|
||||
internal.push({
|
||||
name: 'BookStack',
|
||||
url: bookstack.url,
|
||||
pingUrl: `${bookstack.url}/api/pages?count=1`,
|
||||
headers: {
|
||||
'Authorization': `Token ${bookstack.tokenId}:${bookstack.tokenSecret}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (vikunja.url) {
|
||||
internal.push({
|
||||
name: 'Vikunja',
|
||||
url: vikunja.url,
|
||||
pingUrl: `${vikunja.url}/api/v1/tasks/all?per_page=1`,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${vikunja.apiToken}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return internal;
|
||||
}
|
||||
}
|
||||
|
||||
export default new ServiceMonitorService();
|
||||
@@ -23,6 +23,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"dompurify": "^2.5.8",
|
||||
"recharts": "^2.12.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -24,6 +24,8 @@ import Kalender from './pages/Kalender';
|
||||
import UebungDetail from './pages/UebungDetail';
|
||||
import Veranstaltungen from './pages/Veranstaltungen';
|
||||
import VeranstaltungKategorien from './pages/VeranstaltungKategorien';
|
||||
import Wissen from './pages/Wissen';
|
||||
import AdminDashboard from './pages/AdminDashboard';
|
||||
import NotFound from './pages/NotFound';
|
||||
|
||||
function App() {
|
||||
@@ -203,6 +205,22 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/wissen"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Wissen />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminDashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
|
||||
131
frontend/src/components/admin/NotificationBroadcastTab.tsx
Normal file
131
frontend/src/components/admin/NotificationBroadcastTab.tsx
Normal 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;
|
||||
205
frontend/src/components/admin/ServiceManagerTab.tsx
Normal file
205
frontend/src/components/admin/ServiceManagerTab.tsx
Normal 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;
|
||||
117
frontend/src/components/admin/SystemHealthTab.tsx
Normal file
117
frontend/src/components/admin/SystemHealthTab.tsx
Normal 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;
|
||||
166
frontend/src/components/admin/UserOverviewTab.tsx
Normal file
166
frontend/src/components/admin/UserOverviewTab.tsx
Normal 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;
|
||||
72
frontend/src/components/chat/ChatMessage.tsx
Normal file
72
frontend/src/components/chat/ChatMessage.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import type { NextcloudMessage } from '../../types/nextcloud.types';
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: NextcloudMessage;
|
||||
isOwnMessage: boolean;
|
||||
}
|
||||
|
||||
const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage }) => {
|
||||
const time = new Date(message.timestamp * 1000).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
if (message.systemMessage) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', my: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
|
||||
{message.message} - {time}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: isOwnMessage ? 'flex-end' : 'flex-start',
|
||||
my: 0.5,
|
||||
px: 1,
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 0.75,
|
||||
maxWidth: '80%',
|
||||
bgcolor: isOwnMessage ? 'primary.main' : (theme) => theme.palette.mode === 'dark' ? 'grey.800' : 'grey.200',
|
||||
color: isOwnMessage ? 'primary.contrastText' : 'text.primary',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
{!isOwnMessage && (
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block' }}>
|
||||
{message.actorDisplayName}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{message.message}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
display: 'block',
|
||||
textAlign: 'right',
|
||||
mt: 0.25,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
{time}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessage;
|
||||
119
frontend/src/components/chat/ChatMessageView.tsx
Normal file
119
frontend/src/components/chat/ChatMessageView.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { nextcloudApi } from '../../services/nextcloud';
|
||||
import { useChat } from '../../contexts/ChatContext';
|
||||
import { useLayout } from '../../contexts/LayoutContext';
|
||||
import ChatMessage from './ChatMessage';
|
||||
|
||||
const ChatMessageView: React.FC = () => {
|
||||
const { selectedRoomToken, selectRoom, rooms, loginName } = useChat();
|
||||
const { chatPanelOpen } = useLayout();
|
||||
const queryClient = useQueryClient();
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const room = rooms.find((r) => r.token === selectedRoomToken);
|
||||
|
||||
const { data: messages } = useQuery({
|
||||
queryKey: ['nextcloud', 'messages', selectedRoomToken],
|
||||
queryFn: () => nextcloudApi.getMessages(selectedRoomToken!),
|
||||
enabled: !!selectedRoomToken && chatPanelOpen,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: (message: string) => nextcloudApi.sendMessage(selectedRoomToken!, message),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'messages', selectedRoomToken] });
|
||||
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRoomToken && chatPanelOpen) {
|
||||
nextcloudApi.markAsRead(selectedRoomToken).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, [selectedRoomToken, chatPanelOpen, queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = () => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || !selectedRoomToken) return;
|
||||
sendMutation.mutate(trimmed);
|
||||
setInput('');
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedRoomToken) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1 }}>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Raum auswählen
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
||||
<Box sx={{ px: 1.5, py: 1, borderBottom: 1, borderColor: 'divider', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<IconButton size="small" onClick={() => selectRoom(null)}>
|
||||
<ArrowBackIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Typography variant="subtitle2" noWrap>
|
||||
{room?.displayName ?? selectedRoomToken}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, overflow: 'auto', py: 1 }}>
|
||||
{messages?.map((msg) => (
|
||||
<ChatMessage
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
isOwnMessage={msg.actorType === 'users' && msg.actorId === loginName}
|
||||
/>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 1, borderTop: 1, borderColor: 'divider', display: 'flex', gap: 0.5 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="Nachricht..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
multiline
|
||||
maxRows={3}
|
||||
/>
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || sendMutation.isPending}
|
||||
>
|
||||
<SendIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessageView;
|
||||
93
frontend/src/components/chat/ChatPanel.tsx
Normal file
93
frontend/src/components/chat/ChatPanel.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ChatIcon from '@mui/icons-material/Chat';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useLayout } from '../../contexts/LayoutContext';
|
||||
import { ChatProvider, useChat } from '../../contexts/ChatContext';
|
||||
import ChatRoomList from './ChatRoomList';
|
||||
import ChatMessageView from './ChatMessageView';
|
||||
|
||||
const ChatPanelInner: React.FC = () => {
|
||||
const { chatPanelOpen, setChatPanelOpen } = useLayout();
|
||||
const { selectedRoomToken, connected } = useChat();
|
||||
|
||||
if (!chatPanelOpen) {
|
||||
return (
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
width: 60,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
pt: 1,
|
||||
flexShrink: 0,
|
||||
transition: 'width 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<IconButton onClick={() => setChatPanelOpen(true)}>
|
||||
<ChatIcon />
|
||||
</IconButton>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
width: 360,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexShrink: 0,
|
||||
transition: 'width 0.2s ease',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
Chat
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={() => setChatPanelOpen(false)}>
|
||||
<ChatIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{!connected ? (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Nextcloud nicht verbunden. Bitte verbinden Sie sich in den Einstellungen.
|
||||
</Typography>
|
||||
</Box>
|
||||
) : selectedRoomToken ? (
|
||||
<ChatMessageView />
|
||||
) : (
|
||||
<ChatRoomList />
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatPanel: React.FC = () => {
|
||||
return (
|
||||
<ChatProvider>
|
||||
<ChatPanelInner />
|
||||
</ChatProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatPanel;
|
||||
48
frontend/src/components/chat/ChatRoomList.tsx
Normal file
48
frontend/src/components/chat/ChatRoomList.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import List from '@mui/material/List';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useChat } from '../../contexts/ChatContext';
|
||||
|
||||
const ChatRoomList: React.FC = () => {
|
||||
const { rooms, selectedRoomToken, selectRoom } = useChat();
|
||||
|
||||
return (
|
||||
<Box sx={{ overflow: 'auto', flex: 1 }}>
|
||||
<List disablePadding>
|
||||
{rooms.map((room) => (
|
||||
<ListItemButton
|
||||
key={room.token}
|
||||
selected={room.token === selectedRoomToken}
|
||||
onClick={() => selectRoom(room.token)}
|
||||
sx={{ py: 1, px: 1.5 }}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="subtitle2" noWrap sx={{ flex: 1 }}>
|
||||
{room.displayName}
|
||||
</Typography>
|
||||
{room.unreadMessages > 0 && (
|
||||
<Badge
|
||||
badgeContent={room.unreadMessages}
|
||||
color="primary"
|
||||
sx={{ ml: 1 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{room.lastMessage && (
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{room.lastMessage.author}: {room.lastMessage.text}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatRoomList;
|
||||
67
frontend/src/components/dashboard/AdminStatusWidget.tsx
Normal file
67
frontend/src/components/dashboard/AdminStatusWidget.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Card, CardContent, Typography, Box, Chip } from '@mui/material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { MonitorHeartOutlined } from '@mui/icons-material';
|
||||
import { adminApi } from '../../services/admin';
|
||||
import { useCountUp } from '../../hooks/useCountUp';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
function AdminStatusWidget() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['admin-status-summary'],
|
||||
queryFn: () => adminApi.getStatusSummary(),
|
||||
refetchInterval: 30_000,
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
const up = useCountUp(data?.up ?? 0);
|
||||
const total = useCountUp(data?.total ?? 0);
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
const allUp = data && data.up === data.total;
|
||||
const majorityDown = data && data.total > 0 && data.up < data.total / 2;
|
||||
const color = allUp ? 'success' : majorityDown ? 'error' : 'warning';
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{ cursor: 'pointer', '&:hover': { boxShadow: 4 } }}
|
||||
onClick={() => navigate('/admin')}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<MonitorHeartOutlined color={color} />
|
||||
<Typography variant="h6" component="div">
|
||||
Service Status
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, mb: 1 }}>
|
||||
<Typography variant="h3" component="span" sx={{ fontWeight: 700 }}>
|
||||
{up}
|
||||
</Typography>
|
||||
<Typography variant="h5" component="span" color="text.secondary">
|
||||
/ {total}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ ml: 0.5 }}>
|
||||
Services online
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Chip
|
||||
label={allUp ? 'Alle aktiv' : majorityDown ? 'Kritisch' : 'Teilweise gestört'}
|
||||
color={color}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminStatusWidget;
|
||||
@@ -4,14 +4,17 @@ import Header from '../shared/Header';
|
||||
import Sidebar from '../shared/Sidebar';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import Loading from '../shared/Loading';
|
||||
import { LayoutProvider, useLayout, DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED } from '../../contexts/LayoutContext';
|
||||
import ChatPanel from '../chat/ChatPanel';
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
function DashboardLayoutInner({ children }: DashboardLayoutProps) {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const { isLoading } = useAuth();
|
||||
const { sidebarCollapsed, chatPanelOpen } = useLayout();
|
||||
|
||||
const handleDrawerToggle = () => {
|
||||
setMobileOpen(!mobileOpen);
|
||||
@@ -21,6 +24,9 @@ function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
return <Loading message="Lade Dashboard..." />;
|
||||
}
|
||||
|
||||
const sidebarWidth = sidebarCollapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH;
|
||||
const chatWidth = chatPanelOpen ? 360 : 60;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<Header onMenuClick={handleDrawerToggle} />
|
||||
@@ -31,16 +37,27 @@ function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
p: 3,
|
||||
width: { sm: `calc(100% - 240px)` },
|
||||
width: { sm: `calc(100% - ${sidebarWidth}px - ${chatWidth}px)` },
|
||||
minHeight: '100vh',
|
||||
backgroundColor: 'background.default',
|
||||
transition: 'width 225ms cubic-bezier(0.4, 0, 0.6, 1)',
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
<ChatPanel />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
return (
|
||||
<LayoutProvider>
|
||||
<DashboardLayoutInner>{children}</DashboardLayoutInner>
|
||||
</LayoutProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardLayout;
|
||||
|
||||
@@ -9,3 +9,4 @@ export { default as BookStackSearchWidget } from './BookStackSearchWidget';
|
||||
export { default as VikunjaMyTasksWidget } from './VikunjaMyTasksWidget';
|
||||
export { default as VikunjaQuickAddWidget } from './VikunjaQuickAddWidget';
|
||||
export { default as VikunjaOverdueNotifier } from './VikunjaOverdueNotifier';
|
||||
export { default as AdminStatusWidget } from './AdminStatusWidget';
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ListItemIcon,
|
||||
Divider,
|
||||
Box,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
LocalFireDepartment,
|
||||
@@ -17,10 +18,15 @@ import {
|
||||
Settings,
|
||||
Logout,
|
||||
Menu as MenuIcon,
|
||||
Launch,
|
||||
Chat,
|
||||
} from '@mui/icons-material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import NotificationBell from './NotificationBell';
|
||||
import { configApi } from '../../services/config';
|
||||
import { useLayout } from '../../contexts/LayoutContext';
|
||||
|
||||
interface HeaderProps {
|
||||
onMenuClick: () => void;
|
||||
@@ -29,7 +35,16 @@ interface HeaderProps {
|
||||
function Header({ onMenuClick }: HeaderProps) {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { toggleChatPanel } = useLayout();
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [toolsAnchorEl, setToolsAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
const { data: externalLinks } = useQuery({
|
||||
queryKey: ['external-links'],
|
||||
queryFn: () => configApi.getExternalLinks(),
|
||||
staleTime: 10 * 60 * 1000,
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
@@ -39,6 +54,14 @@ function Header({ onMenuClick }: HeaderProps) {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleToolsOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setToolsAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleToolsClose = () => {
|
||||
setToolsAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleProfile = () => {
|
||||
handleMenuClose();
|
||||
navigate('/profile');
|
||||
@@ -54,6 +77,11 @@ function Header({ onMenuClick }: HeaderProps) {
|
||||
logout();
|
||||
};
|
||||
|
||||
const handleOpenExternal = (url: string) => {
|
||||
handleToolsClose();
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
// Get initials for avatar
|
||||
const getInitials = () => {
|
||||
if (!user) return '?';
|
||||
@@ -61,6 +89,16 @@ function Header({ onMenuClick }: HeaderProps) {
|
||||
return initials || user.name?.[0] || '?';
|
||||
};
|
||||
|
||||
const linkEntries = externalLinks
|
||||
? Object.entries(externalLinks).filter(([, url]) => !!url)
|
||||
: [];
|
||||
|
||||
const linkLabels: Record<string, string> = {
|
||||
nextcloud: 'Nextcloud',
|
||||
bookstack: 'BookStack',
|
||||
vikunja: 'Vikunja',
|
||||
};
|
||||
|
||||
return (
|
||||
<AppBar
|
||||
position="fixed"
|
||||
@@ -71,7 +109,7 @@ function Header({ onMenuClick }: HeaderProps) {
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="Men� �ffnen"
|
||||
aria-label="Menü öffnen"
|
||||
edge="start"
|
||||
onClick={onMenuClick}
|
||||
sx={{ mr: 2, display: { sm: 'none' } }}
|
||||
@@ -86,6 +124,63 @@ function Header({ onMenuClick }: HeaderProps) {
|
||||
|
||||
{user && (
|
||||
<>
|
||||
{linkEntries.length > 0 && (
|
||||
<>
|
||||
<Tooltip title="Externe Tools">
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={handleToolsOpen}
|
||||
size="small"
|
||||
aria-label="Externe Tools"
|
||||
aria-controls="tools-menu"
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<Launch />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Menu
|
||||
id="tools-menu"
|
||||
anchorEl={toolsAnchorEl}
|
||||
open={Boolean(toolsAnchorEl)}
|
||||
onClose={handleToolsClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
PaperProps={{
|
||||
elevation: 3,
|
||||
sx: { minWidth: 180, mt: 1 },
|
||||
}}
|
||||
>
|
||||
{linkEntries.map(([key, url]) => (
|
||||
<MenuItem key={key} onClick={() => handleOpenExternal(url)}>
|
||||
<ListItemIcon>
|
||||
<Launch fontSize="small" />
|
||||
</ListItemIcon>
|
||||
{linkLabels[key] || key}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tooltip title="Chat">
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={toggleChatPanel}
|
||||
size="small"
|
||||
aria-label="Chat öffnen"
|
||||
sx={{ ml: 0.5 }}
|
||||
>
|
||||
<Chat />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<NotificationBell />
|
||||
|
||||
<IconButton
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Drawer,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
@@ -15,10 +18,16 @@ import {
|
||||
People,
|
||||
Air,
|
||||
CalendarMonth,
|
||||
MenuBook,
|
||||
AdminPanelSettings,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useLayout, DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED } from '../../contexts/LayoutContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
const DRAWER_WIDTH = 240;
|
||||
export { DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED };
|
||||
|
||||
interface NavigationItem {
|
||||
text: string;
|
||||
@@ -26,7 +35,7 @@ interface NavigationItem {
|
||||
path: string;
|
||||
}
|
||||
|
||||
const navigationItems: NavigationItem[] = [
|
||||
const baseNavigationItems: NavigationItem[] = [
|
||||
{
|
||||
text: 'Dashboard',
|
||||
icon: <DashboardIcon />,
|
||||
@@ -57,8 +66,19 @@ const navigationItems: NavigationItem[] = [
|
||||
icon: <Air />,
|
||||
path: '/atemschutz',
|
||||
},
|
||||
{
|
||||
text: 'Wissen',
|
||||
icon: <MenuBook />,
|
||||
path: '/wissen',
|
||||
},
|
||||
];
|
||||
|
||||
const adminItem: NavigationItem = {
|
||||
text: 'Admin',
|
||||
icon: <AdminPanelSettings />,
|
||||
path: '/admin',
|
||||
};
|
||||
|
||||
interface SidebarProps {
|
||||
mobileOpen: boolean;
|
||||
onMobileClose: () => void;
|
||||
@@ -67,6 +87,14 @@ interface SidebarProps {
|
||||
function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { sidebarCollapsed, toggleSidebar } = useLayout();
|
||||
const { user } = useAuth();
|
||||
|
||||
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
|
||||
|
||||
const navigationItems = useMemo(() => {
|
||||
return isAdmin ? [...baseNavigationItems, adminItem] : baseNavigationItems;
|
||||
}, [isAdmin]);
|
||||
|
||||
const handleNavigation = (path: string) => {
|
||||
navigate(path);
|
||||
@@ -74,19 +102,25 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
};
|
||||
|
||||
const drawerContent = (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Toolbar />
|
||||
<List>
|
||||
<List sx={{ flex: 1 }}>
|
||||
{navigationItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<ListItem key={item.text} disablePadding>
|
||||
<Tooltip title={item.text} placement="right" arrow>
|
||||
<Tooltip
|
||||
title={item.text}
|
||||
placement="right"
|
||||
arrow
|
||||
disableHoverListener={!sidebarCollapsed}
|
||||
>
|
||||
<ListItemButton
|
||||
selected={isActive}
|
||||
onClick={() => handleNavigation(item.path)}
|
||||
aria-label={`Zu ${item.text} navigieren`}
|
||||
sx={{
|
||||
justifyContent: sidebarCollapsed ? 'center' : 'initial',
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: 'primary.light',
|
||||
color: 'primary.contrastText',
|
||||
@@ -102,18 +136,30 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
<ListItemIcon
|
||||
sx={{
|
||||
color: isActive ? 'inherit' : 'text.secondary',
|
||||
minWidth: sidebarCollapsed ? 0 : undefined,
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={item.text} />
|
||||
<ListItemText
|
||||
primary={item.text}
|
||||
sx={{
|
||||
display: sidebarCollapsed ? 'none' : 'block',
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</Tooltip>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 1 }}>
|
||||
<IconButton onClick={toggleSidebar} aria-label="Sidebar umschalten">
|
||||
{sidebarCollapsed ? <ChevronRight /> : <ChevronLeft />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -143,11 +189,13 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
variant="permanent"
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
width: DRAWER_WIDTH,
|
||||
width: sidebarCollapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH,
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': {
|
||||
width: DRAWER_WIDTH,
|
||||
width: sidebarCollapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH,
|
||||
boxSizing: 'border-box',
|
||||
transition: 'width 225ms cubic-bezier(0.4, 0, 0.6, 1)',
|
||||
overflowX: 'hidden',
|
||||
},
|
||||
}}
|
||||
open
|
||||
|
||||
57
frontend/src/contexts/ChatContext.tsx
Normal file
57
frontend/src/contexts/ChatContext.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { nextcloudApi } from '../services/nextcloud';
|
||||
import { useLayout } from './LayoutContext';
|
||||
import type { NextcloudConversation } from '../types/nextcloud.types';
|
||||
|
||||
interface ChatContextType {
|
||||
rooms: NextcloudConversation[];
|
||||
selectedRoomToken: string | null;
|
||||
selectRoom: (token: string | null) => void;
|
||||
connected: boolean;
|
||||
loginName: string | null;
|
||||
}
|
||||
|
||||
const ChatContext = createContext<ChatContextType | undefined>(undefined);
|
||||
|
||||
interface ChatProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
|
||||
const [selectedRoomToken, setSelectedRoomToken] = useState<string | null>(null);
|
||||
const { chatPanelOpen } = useLayout();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['nextcloud', 'rooms'],
|
||||
queryFn: () => nextcloudApi.getRooms(),
|
||||
refetchInterval: chatPanelOpen ? 30000 : false,
|
||||
enabled: chatPanelOpen,
|
||||
});
|
||||
|
||||
const rooms = data?.rooms ?? [];
|
||||
const connected = data?.connected ?? false;
|
||||
const loginName = data?.loginName ?? null;
|
||||
|
||||
const selectRoom = useCallback((token: string | null) => {
|
||||
setSelectedRoomToken(token);
|
||||
}, []);
|
||||
|
||||
const value: ChatContextType = {
|
||||
rooms,
|
||||
selectedRoomToken,
|
||||
selectRoom,
|
||||
connected,
|
||||
loginName,
|
||||
};
|
||||
|
||||
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
|
||||
};
|
||||
|
||||
export const useChat = (): ChatContextType => {
|
||||
const context = useContext(ChatContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useChat must be used within a ChatProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
70
frontend/src/contexts/LayoutContext.tsx
Normal file
70
frontend/src/contexts/LayoutContext.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
|
||||
export const DRAWER_WIDTH = 240;
|
||||
export const DRAWER_WIDTH_COLLAPSED = 64;
|
||||
|
||||
interface LayoutContextType {
|
||||
sidebarCollapsed: boolean;
|
||||
chatPanelOpen: boolean;
|
||||
toggleSidebar: () => void;
|
||||
toggleChatPanel: () => void;
|
||||
setChatPanelOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const LayoutContext = createContext<LayoutContextType | undefined>(undefined);
|
||||
|
||||
function getInitialCollapsed(): boolean {
|
||||
try {
|
||||
const stored = localStorage.getItem('sidebar-collapsed');
|
||||
return stored === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface LayoutProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(getInitialCollapsed);
|
||||
const [chatPanelOpen, setChatPanelOpenState] = useState(false);
|
||||
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setSidebarCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
localStorage.setItem('sidebar-collapsed', String(next));
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleChatPanel = useCallback(() => {
|
||||
setChatPanelOpenState((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const setChatPanelOpen = useCallback((open: boolean) => {
|
||||
setChatPanelOpenState(open);
|
||||
}, []);
|
||||
|
||||
const value: LayoutContextType = {
|
||||
sidebarCollapsed,
|
||||
chatPanelOpen,
|
||||
toggleSidebar,
|
||||
toggleChatPanel,
|
||||
setChatPanelOpen,
|
||||
};
|
||||
|
||||
return <LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>;
|
||||
};
|
||||
|
||||
export const useLayout = (): LayoutContextType => {
|
||||
const context = useContext(LayoutContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useLayout must be used within a LayoutProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
7
frontend/src/dompurify.d.ts
vendored
Normal file
7
frontend/src/dompurify.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
declare module 'dompurify' {
|
||||
interface DOMPurifyI {
|
||||
sanitize(source: string | Node, config?: Record<string, unknown>): string;
|
||||
}
|
||||
const DOMPurify: DOMPurifyI;
|
||||
export default DOMPurify;
|
||||
}
|
||||
42
frontend/src/hooks/useCountUp.ts
Normal file
42
frontend/src/hooks/useCountUp.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
export function useCountUp(target: number, duration: number = 1000): number {
|
||||
const [current, setCurrent] = useState(0);
|
||||
const rafRef = useRef<number>(0);
|
||||
const currentRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (target === 0) {
|
||||
setCurrent(0);
|
||||
currentRef.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
const startValue = currentRef.current;
|
||||
|
||||
const animate = (now: number) => {
|
||||
const elapsed = now - startTime;
|
||||
const t = Math.min(elapsed / duration, 1);
|
||||
// ease-out cubic: 1 - (1-t)^3
|
||||
const eased = 1 - Math.pow(1 - t, 3);
|
||||
const value = Math.round(startValue + (target - startValue) * eased);
|
||||
setCurrent(value);
|
||||
currentRef.current = value;
|
||||
|
||||
if (t < 1) {
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
};
|
||||
}, [target, duration]);
|
||||
|
||||
return current;
|
||||
}
|
||||
61
frontend/src/pages/AdminDashboard.tsx
Normal file
61
frontend/src/pages/AdminDashboard.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, Tabs, Tab, Typography } from '@mui/material';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import ServiceManagerTab from '../components/admin/ServiceManagerTab';
|
||||
import SystemHealthTab from '../components/admin/SystemHealthTab';
|
||||
import UserOverviewTab from '../components/admin/UserOverviewTab';
|
||||
import NotificationBroadcastTab from '../components/admin/NotificationBroadcastTab';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
interface TabPanelProps {
|
||||
children: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
function TabPanel({ children, value, index }: TabPanelProps) {
|
||||
if (value !== index) return null;
|
||||
return <Box sx={{ pt: 3 }}>{children}</Box>;
|
||||
}
|
||||
|
||||
function AdminDashboard() {
|
||||
const [tab, setTab] = useState(0);
|
||||
const { user } = useAuth();
|
||||
|
||||
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
|
||||
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Typography variant="h4" sx={{ mb: 3 }}>Administration</Typography>
|
||||
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tab} onChange={(_e, v) => setTab(v)}>
|
||||
<Tab label="Services" />
|
||||
<Tab label="System" />
|
||||
<Tab label="Benutzer" />
|
||||
<Tab label="Broadcast" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
<TabPanel value={tab} index={0}>
|
||||
<ServiceManagerTab />
|
||||
</TabPanel>
|
||||
<TabPanel value={tab} index={1}>
|
||||
<SystemHealthTab />
|
||||
</TabPanel>
|
||||
<TabPanel value={tab} index={2}>
|
||||
<UserOverviewTab />
|
||||
</TabPanel>
|
||||
<TabPanel value={tab} index={3}>
|
||||
<NotificationBroadcastTab />
|
||||
</TabPanel>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminDashboard;
|
||||
@@ -18,8 +18,10 @@ import BookStackSearchWidget from '../components/dashboard/BookStackSearchWidget
|
||||
import VikunjaMyTasksWidget from '../components/dashboard/VikunjaMyTasksWidget';
|
||||
import VikunjaQuickAddWidget from '../components/dashboard/VikunjaQuickAddWidget';
|
||||
import VikunjaOverdueNotifier from '../components/dashboard/VikunjaOverdueNotifier';
|
||||
import AdminStatusWidget from '../components/dashboard/AdminStatusWidget';
|
||||
function Dashboard() {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
|
||||
const canViewAtemschutz = user?.groups?.some(g =>
|
||||
['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'].includes(g)
|
||||
) ?? false;
|
||||
@@ -148,6 +150,17 @@ function Dashboard() {
|
||||
|
||||
{/* Vikunja — Overdue Notifier (invisible, polling component) */}
|
||||
<VikunjaOverdueNotifier />
|
||||
|
||||
{/* Admin Status Widget — only for admins */}
|
||||
{isAdmin && (
|
||||
<Box>
|
||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '680ms' }}>
|
||||
<Box>
|
||||
<AdminStatusWidget />
|
||||
</Box>
|
||||
</Fade>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
|
||||
240
frontend/src/pages/Wissen.tsx
Normal file
240
frontend/src/pages/Wissen.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Typography,
|
||||
Paper,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
CircularProgress,
|
||||
InputAdornment,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import { Search as SearchIcon } from '@mui/icons-material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import DOMPurify from 'dompurify';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { bookstackApi } from '../services/bookstack';
|
||||
import type { BookStackPage, BookStackSearchResult } from '../types/bookstack.types';
|
||||
|
||||
export default function Wissen() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [selectedPageId, setSelectedPageId] = useState<number | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setDebouncedSearch(searchTerm.trim());
|
||||
}, 400);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [searchTerm]);
|
||||
|
||||
const recentQuery = useQuery({
|
||||
queryKey: ['bookstack', 'recent'],
|
||||
queryFn: () => bookstackApi.getRecent(),
|
||||
});
|
||||
|
||||
const searchQuery = useQuery({
|
||||
queryKey: ['bookstack', 'search', debouncedSearch],
|
||||
queryFn: () => bookstackApi.search(debouncedSearch),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const pageQuery = useQuery({
|
||||
queryKey: ['bookstack', 'page', selectedPageId],
|
||||
queryFn: () => bookstackApi.getPage(selectedPageId!),
|
||||
enabled: selectedPageId !== null,
|
||||
});
|
||||
|
||||
const handleSelectPage = useCallback((id: number) => {
|
||||
setSelectedPageId(id);
|
||||
}, []);
|
||||
|
||||
const isNotConfigured =
|
||||
recentQuery.data && !recentQuery.data.configured;
|
||||
|
||||
if (isNotConfigured) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Wissen
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
BookStack ist nicht konfiguriert. Bitte BOOKSTACK_URL, BOOKSTACK_TOKEN_ID und
|
||||
BOOKSTACK_TOKEN_SECRET in der .env-Datei setzen.
|
||||
</Typography>
|
||||
</Box>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const isSearching = debouncedSearch.length > 0;
|
||||
const listItems: (BookStackSearchResult | BookStackPage)[] = isSearching
|
||||
? searchQuery.data?.data ?? []
|
||||
: recentQuery.data?.data ?? [];
|
||||
const listLoading = isSearching ? searchQuery.isLoading : recentQuery.isLoading;
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box sx={{ display: 'flex', height: 'calc(100vh - 120px)', gap: 2, p: 2 }}>
|
||||
{/* Left panel: search + list */}
|
||||
<Paper sx={{ width: '40%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Seiten durchsuchen..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
||||
{listLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
) : listItems.length === 0 ? (
|
||||
<Typography sx={{ p: 2 }} color="text.secondary">
|
||||
{isSearching ? 'Keine Ergebnisse gefunden.' : 'Keine Seiten vorhanden.'}
|
||||
</Typography>
|
||||
) : (
|
||||
<List disablePadding>
|
||||
{listItems.map((item) => (
|
||||
<ListItem key={item.id} disablePadding>
|
||||
<ListItemButton
|
||||
selected={selectedPageId === item.id}
|
||||
onClick={() => handleSelectPage(item.id)}
|
||||
>
|
||||
<ListItemText
|
||||
primary={item.name}
|
||||
secondary={
|
||||
'book' in item && item.book
|
||||
? item.book.name
|
||||
: undefined
|
||||
}
|
||||
primaryTypographyProps={{ noWrap: true }}
|
||||
secondaryTypographyProps={{ noWrap: true }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Right panel: page content */}
|
||||
<Paper sx={{ width: '60%', overflow: 'auto', p: 3 }}>
|
||||
{!selectedPageId ? (
|
||||
<Typography color="text.secondary">
|
||||
Seite aus der Liste auswaehlen, um den Inhalt anzuzeigen.
|
||||
</Typography>
|
||||
) : pageQuery.isLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : pageQuery.isError ? (
|
||||
<Typography color="error">
|
||||
Fehler beim Laden der Seite.
|
||||
</Typography>
|
||||
) : pageQuery.data?.data ? (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
{pageQuery.data.data.name}
|
||||
</Typography>
|
||||
{pageQuery.data.data.book && (
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Buch: {pageQuery.data.data.book.name}
|
||||
</Typography>
|
||||
)}
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Box
|
||||
className="bookstack-content"
|
||||
sx={(theme) => ({
|
||||
'& h1, & h2, & h3, & h4, & h5, & h6': {
|
||||
color: theme.palette.text.primary,
|
||||
mt: 2,
|
||||
mb: 1,
|
||||
},
|
||||
'& p': {
|
||||
color: theme.palette.text.primary,
|
||||
lineHeight: 1.7,
|
||||
mb: 1,
|
||||
},
|
||||
'& a': {
|
||||
color: theme.palette.primary.main,
|
||||
textDecoration: 'none',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
},
|
||||
'& table': {
|
||||
borderCollapse: 'collapse',
|
||||
width: '100%',
|
||||
mb: 2,
|
||||
},
|
||||
'& th, & td': {
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
padding: theme.spacing(1),
|
||||
textAlign: 'left',
|
||||
},
|
||||
'& th': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
fontWeight: 600,
|
||||
},
|
||||
'& img': {
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
borderRadius: 1,
|
||||
},
|
||||
'& code': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
padding: '2px 6px',
|
||||
borderRadius: 1,
|
||||
fontSize: '0.875em',
|
||||
},
|
||||
'& pre': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
padding: theme.spacing(2),
|
||||
borderRadius: 1,
|
||||
overflow: 'auto',
|
||||
},
|
||||
'& ul, & ol': {
|
||||
pl: 3,
|
||||
mb: 1,
|
||||
},
|
||||
'& blockquote': {
|
||||
borderLeft: `4px solid ${theme.palette.primary.main}`,
|
||||
pl: 2,
|
||||
ml: 0,
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
})}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(pageQuery.data.data.html),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography color="text.secondary">
|
||||
Seite nicht gefunden.
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
19
frontend/src/services/admin.ts
Normal file
19
frontend/src/services/admin.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { api } from './api';
|
||||
import type { MonitoredService, PingResult, StatusSummary, SystemHealth, UserOverview, BroadcastPayload } from '../types/admin.types';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
getServices: () => api.get<ApiResponse<MonitoredService[]>>('/api/admin/services').then(r => r.data.data),
|
||||
createService: (data: { name: string; url: string }) => api.post<ApiResponse<MonitoredService>>('/api/admin/services', data).then(r => r.data.data),
|
||||
updateService: (id: string, data: Partial<MonitoredService>) => api.put<ApiResponse<MonitoredService>>(`/api/admin/services/${id}`, data).then(r => r.data.data),
|
||||
deleteService: (id: string) => api.delete(`/api/admin/services/${id}`).then(() => undefined),
|
||||
pingAll: () => api.get<ApiResponse<PingResult[]>>('/api/admin/services/ping').then(r => r.data.data),
|
||||
getStatusSummary: () => api.get<ApiResponse<StatusSummary>>('/api/admin/services/status-summary').then(r => r.data.data),
|
||||
getSystemHealth: () => api.get<ApiResponse<SystemHealth>>('/api/admin/system/health').then(r => r.data.data),
|
||||
getUsers: () => api.get<ApiResponse<UserOverview[]>>('/api/admin/users').then(r => r.data.data),
|
||||
broadcast: (data: BroadcastPayload) => api.post<ApiResponse<{ sent: number }>>('/api/admin/notifications/broadcast', data).then(r => r.data.data),
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api } from './api';
|
||||
import type { BookStackRecentResponse, BookStackSearchResponse } from '../types/bookstack.types';
|
||||
import type { BookStackRecentResponse, BookStackSearchResponse, BookStackPageDetail } from '../types/bookstack.types';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
@@ -14,6 +14,12 @@ export const bookstackApi = {
|
||||
.then((r) => ({ configured: r.data.configured, data: r.data.data }));
|
||||
},
|
||||
|
||||
getPage(id: number): Promise<{ configured: boolean; data: BookStackPageDetail | null }> {
|
||||
return api
|
||||
.get<ApiResponse<BookStackPageDetail | null>>(`/api/bookstack/pages/${id}`)
|
||||
.then((r) => ({ configured: r.data.configured, data: r.data.data }));
|
||||
},
|
||||
|
||||
search(query: string): Promise<BookStackSearchResponse> {
|
||||
return api
|
||||
.get<ApiResponse<BookStackSearchResponse['data']>>('/api/bookstack/search', {
|
||||
|
||||
15
frontend/src/services/config.ts
Normal file
15
frontend/src/services/config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { api } from './api';
|
||||
import type { ExternalLinks } from '../types/config.types';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export const configApi = {
|
||||
getExternalLinks(): Promise<ExternalLinks> {
|
||||
return api
|
||||
.get<ApiResponse<ExternalLinks>>('/api/config/external-links')
|
||||
.then((r) => r.data.data);
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api } from './api';
|
||||
import type { NextcloudTalkData, NextcloudConnectData, NextcloudPollData } from '../types/nextcloud.types';
|
||||
import type { NextcloudTalkData, NextcloudConnectData, NextcloudPollData, NextcloudMessage, NextcloudRoomListData } from '../types/nextcloud.types';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
@@ -30,4 +30,28 @@ export const nextcloudApi = {
|
||||
.delete('/api/nextcloud/talk/connect')
|
||||
.then(() => undefined);
|
||||
},
|
||||
|
||||
getRooms(): Promise<NextcloudRoomListData> {
|
||||
return api
|
||||
.get<ApiResponse<NextcloudRoomListData>>('/api/nextcloud/talk/rooms')
|
||||
.then((r) => r.data.data);
|
||||
},
|
||||
|
||||
getMessages(token: string): Promise<NextcloudMessage[]> {
|
||||
return api
|
||||
.get<ApiResponse<NextcloudMessage[]>>(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/messages`)
|
||||
.then((r) => r.data.data);
|
||||
},
|
||||
|
||||
sendMessage(token: string, message: string): Promise<void> {
|
||||
return api
|
||||
.post(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/messages`, { message })
|
||||
.then(() => undefined);
|
||||
},
|
||||
|
||||
markAsRead(token: string): Promise<void> {
|
||||
return api
|
||||
.post(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/read`)
|
||||
.then(() => undefined);
|
||||
},
|
||||
};
|
||||
|
||||
52
frontend/src/types/admin.types.ts
Normal file
52
frontend/src/types/admin.types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export interface MonitoredService {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
type: 'internal' | 'custom';
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PingResult {
|
||||
name: string;
|
||||
url: string;
|
||||
status: 'up' | 'down';
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StatusSummary {
|
||||
up: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SystemHealth {
|
||||
nodeVersion: string;
|
||||
uptime: number;
|
||||
memoryUsage: {
|
||||
heapUsed: number;
|
||||
heapTotal: number;
|
||||
rss: number;
|
||||
external: number;
|
||||
};
|
||||
dbStatus: boolean;
|
||||
dbSize: string;
|
||||
}
|
||||
|
||||
export interface UserOverview {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
groups: string[];
|
||||
is_active: boolean;
|
||||
last_login_at: string | null;
|
||||
}
|
||||
|
||||
export interface BroadcastPayload {
|
||||
titel: string;
|
||||
nachricht: string;
|
||||
schwere?: 'info' | 'warnung' | 'fehler';
|
||||
targetGroup?: string;
|
||||
}
|
||||
@@ -34,3 +34,24 @@ export interface BookStackSearchResponse {
|
||||
configured: boolean;
|
||||
data: BookStackSearchResult[];
|
||||
}
|
||||
|
||||
export interface BookStackPageDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
book_id: number;
|
||||
book_slug: string;
|
||||
chapter_id: number;
|
||||
html: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
url: string;
|
||||
book?: { name: string };
|
||||
createdBy?: { name: string };
|
||||
updatedBy?: { name: string };
|
||||
}
|
||||
|
||||
export interface BookStackPageDetailResponse {
|
||||
configured: boolean;
|
||||
data: BookStackPageDetail | null;
|
||||
}
|
||||
|
||||
5
frontend/src/types/config.types.ts
Normal file
5
frontend/src/types/config.types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface ExternalLinks {
|
||||
nextcloud?: string;
|
||||
bookstack?: string;
|
||||
vikunja?: string;
|
||||
}
|
||||
@@ -27,3 +27,21 @@ export interface NextcloudConnectData {
|
||||
export interface NextcloudPollData {
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export interface NextcloudMessage {
|
||||
id: number;
|
||||
token: string;
|
||||
actorType: string;
|
||||
actorId: string;
|
||||
actorDisplayName: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
messageType: string;
|
||||
systemMessage: string;
|
||||
}
|
||||
|
||||
export interface NextcloudRoomListData {
|
||||
connected: boolean;
|
||||
rooms: NextcloudConversation[];
|
||||
loginName?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user