feat(dashboard+api): card-based dashboard, camelCase API, UTC fixes

Dashboard: new Syncfusion card layout, global stats, filters, health bars, active event display, client details, bulk restart, 15s auto-refresh, manual refresh toasts
API: standardized responses to camelCase; added serializers.py and updated events endpoints
Time: ensured UTC storage; frontend appends 'Z' for parsing and displays local time
Docs: updated copilot-instructions.md, README.md, TECH-CHANGELOG.md
Program Info: bumped to 2025.1.0-alpha.12 with user-facing changelog
BREAKING: external API consumers must migrate field names from PascalCase to camelCase.
This commit is contained in:
RobbStarkAustria
2025-11-27 20:30:00 +00:00
parent 452ba3033b
commit 6dcf93f0dd
13 changed files with 1282 additions and 350 deletions

View File

@@ -42,6 +42,35 @@ Small multi-service digital signage app (Flask API, React dashboard, MQTT schedu
## Recent changes since last commit
### Latest (November 2025)
- **API Naming Convention Standardization (camelCase)**:
- Backend: Created `server/serializers.py` with `dict_to_camel_case()` utility for consistent JSON serialization
- Events API: `GET /api/events` and `GET /api/events/<id>` now return camelCase fields (`id`, `subject`, `startTime`, `endTime`, `type`, `groupId`, etc.) instead of PascalCase
- Frontend: Dashboard and appointments page updated to consume camelCase API responses
- Appointments page maintains internal PascalCase for Syncfusion scheduler compatibility with automatic mapping from API responses
- **Breaking**: External API consumers must update field names from PascalCase to camelCase
- **UTC Time Handling**:
- Database stores all timestamps in UTC (naive timestamps normalized by backend)
- API returns ISO strings without 'Z' suffix: `"2025-11-27T20:03:00"`
- Frontend: Dashboard and appointments automatically append 'Z' to parse as UTC and display in user's local timezone
- Time formatting functions use `toLocaleTimeString('de-DE')` for German locale display
- All time comparisons use UTC; `new Date().toISOString()` sends UTC back to API
- **Dashboard Enhancements**:
- New card-based design for Raumgruppen (room groups) with Syncfusion components
- Global statistics summary: total infoscreens, online/offline counts, warning groups
- Filter buttons: All, Online, Offline, Warnings with dynamic counts
- Active event display per group: shows currently playing content with type icon, title, date, and time
- Health visualization with color-coded progress bars per group
- Expandable client details with last alive timestamps
- Bulk restart functionality for offline clients per group
- Manual refresh button with toast notifications
- 15-second auto-refresh interval
### Earlier changes
- Scheduler: when formatting video events the scheduler now performs a best-effort HEAD probe of the streaming URL and includes basic metadata in the emitted payload (mime_type, size, accept_ranges). Placeholders for richer metadata (duration, resolution, bitrate, qualities, thumbnails, checksum) are included for later population by a background worker.
- Streaming endpoint: a range-capable streaming endpoint was added at `/api/eventmedia/stream/<media_id>/<filename>` that supports byte-range requests (206 Partial Content) to enable seeking from clients.
- Event model & API: `Event` gained video-related fields (`event_media_id`, `autoplay`, `loop`, `volume`) and the API accepts and persists these when creating/updating video events.
@@ -116,7 +145,9 @@ Small multi-service digital signage app (Flask API, React dashboard, MQTT schedu
- Vite React app; proxies `/api` and `/screenshots` to API in dev (`vite.config.ts`).
- Uses Syncfusion components; Vite config pre-bundles specific packages to avoid alias issues.
- Environment: `VITE_API_URL` provided at build/run; in dev compose, proxy handles `/api` so local fetches can use relative `/api/...` paths.
- Theming: Syncfusion Material 3 theme is used. All component CSS is imported centrally in `dashboard/src/main.tsx` (base, navigations, buttons, inputs, dropdowns, popups, kanban, grids, schedule, filemanager, notifications, layouts, lists, calendars, splitbuttons, icons). Tailwind CSS has been removed.
- **API Response Format**: All API endpoints return camelCase JSON (e.g., `startTime`, `endTime`, `groupId`). Frontend consumes camelCase directly.
- **UTC Time Parsing**: API returns ISO strings without 'Z' suffix. Frontend appends 'Z' before parsing to ensure UTC interpretation: `const utcString = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'; new Date(utcString);`. Display uses `toLocaleTimeString('de-DE')` for German format.
- Theming: Syncfusion Material 3 theme is used. All component CSS is imported centrally in `dashboard/src/main.tsx` (base, navigations, buttons, inputs, dropdowns, popups, kanban, grids, schedule, filemanager, notifications, layouts, lists, calendars, splitbuttons, icons). Tailwind CSS has been removed.
- Scheduler (appointments page): top bar includes Group and Academic Period selectors (Syncfusion DropDownList). Selecting a period calls `POST /api/academic_periods/active`, moves the calendar to todays month/day within the period year, and refreshes a right-aligned indicator row showing:
- Holidays present in the current view (count)
- Period label (display_name or name) with a badge indicating whether any holidays exist in that period (overlap check)
@@ -169,6 +200,19 @@ Small multi-service digital signage app (Flask API, React dashboard, MQTT schedu
- API clients use relative `/api/...` URLs so Vite dev proxy handles requests without CORS issues. The settings UI calls are centralized in `dashboard/src/apiSystemSettings.ts`.
- Nested tabs: implemented as controlled components using `selectedItem` with stateful handlers to prevent sub-tab resets during updates.
- Dashboard page (`dashboard/src/dashboard.tsx`):
- Card-based overview of all Raumgruppen (room groups) with real-time status monitoring
- Global statistics: total infoscreens, online/offline counts, warning groups
- Filter buttons: All / Online / Offline / Warnings with dynamic counts
- Per-group cards show:
- Currently active event (title, type, date/time in local timezone)
- Health bar with online/offline ratio and color-coded status
- Expandable client list with last alive timestamps
- Bulk restart button for offline clients
- Uses Syncfusion ButtonComponent, ToastComponent, and card CSS classes
- Auto-refresh every 15 seconds; manual refresh button available
- "Nicht zugeordnet" group always appears last in sorted list
- User dropdown technical notes:
- Dependencies: `@syncfusion/ej2-react-splitbuttons` and `@syncfusion/ej2-splitbuttons` must be installed.
- Vite: add both to `optimizeDeps.include` in `vite.config.ts` to avoid import-analysis errors.
@@ -201,8 +245,19 @@ Note: Syncfusion usage in the dashboard is already documented above; if a UI for
- REFRESH_SECONDS — Optional scheduler republish interval; `0` disables periodic refresh.
## Conventions & gotchas
- **Datetime Handling**:
- Always compare datetimes in UTC; some DB values may be naive—normalize before comparing (see `routes/events.py`).
- Scheduler enforces UTC comparisons and normalizes naive timestamps. It publishes only currently active events and clears retained topics for groups with no active events. It also queries a future window (default: 7 days) and expands recurring events using RFC 5545 rules. Event exceptions are respected. Logging is concise and conversion lookups are cached.
- Database stores timestamps in UTC (naive datetimes are normalized to UTC by backend)
- API returns ISO strings **without** 'Z' suffix: `"2025-11-27T20:03:00"`
- Frontend **must** append 'Z' before parsing: `const utcStr = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'; new Date(utcStr);`
- Display in local timezone using `toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })`
- When sending to API, use `date.toISOString()` which includes 'Z' and is UTC
- **JSON Naming Convention**:
- Backend uses snake_case internally (Python convention)
- API returns camelCase JSON (web standard): `startTime`, `endTime`, `groupId`, etc.
- Use `dict_to_camel_case()` from `server/serializers.py` before `jsonify()`
- Frontend consumes camelCase directly; Syncfusion scheduler maintains internal PascalCase with field mappings
- Scheduler enforces UTC comparisons and normalizes naive timestamps. It publishes only currently active events and clears retained topics for groups with no active events. It also queries a future window (default: 7 days) and expands recurring events using RFC 5545 rules. Event exceptions are respected. Logging is concise and conversion lookups are cached.
- Use retained MQTT messages for state that clients must recover after reconnect (events per group, client group_id).
- Clients should parse `event_type` and then read the corresponding nested payload (`presentation`, `website`, `video`, etc.). `website` and `webuntis` use the same nested `website` payload with `type: browser` and a `url`. Video events include `autoplay`, `loop`, `volume`, and `muted`.
- In-container DB host is `db`; do not use `localhost` inside services.
@@ -210,11 +265,12 @@ Note: Syncfusion usage in the dashboard is already documented above; if a UI for
- When adding a new route:
1) Create a Blueprint in `server/routes/...`,
2) Register it in `server/wsgi.py`,
3) Manage `Session()` lifecycle, and
4) Return JSON-safe values (serialize enums and datetimes).
3) Manage `Session()` lifecycle,
4) Return JSON-safe values (serialize enums and datetimes), and
5) Use `dict_to_camel_case()` for camelCase JSON responses
- When extending media types, update `MediaType` and any logic in `eventmedia` and dashboard that depends on it.
- Academic periods: Events/media can be optionally associated with periods for educational organization. Only one period should be active at a time (`is_active=True`).
- Initialization scripts: legacy DB init scripts were removed; use Alembic and `initialize_database.py` going forward.
- Initialization scripts: legacy DB init scripts were removed; use Alembic and `initialize_database.py` going forward.
### Recurrence & holidays: conventions
- Do not pre-expand recurrences on the backend. Always send master events with `RecurrenceRule` + `RecurrenceException`.

135
.gitignore vendored
View File

@@ -1,75 +1,7 @@
# OS/Editor
.DS_Store
Thumbs.db
.vscode/
.idea/
# Python
__pycache__/
*.pyc
.pytest_cache/
# Node
node_modules/
dashboard/node_modules/
dashboard/.vite/
# Env files (never commit secrets)
.env
.env.local
# Docker
*.log
# Python-related
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.pdb
*.egg-info/
*.eggs/
*.env
.env
# Byte-compiled / optimized / DLL files
*.pyc
*.pyo
*.pyd
# Virtual environments
venv/
env/
.venv/
.env/
# Logs and databases
*.log
*.sqlite3
*.db
# Docker-related
*.pid
*.tar
docker-compose.override.yml
docker-compose.override.*.yml
docker-compose.override.*.yaml
# Node.js-related
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Dash and Flask cache
*.cache
*.pytest_cache/
instance/
*.mypy_cache/
*.hypothesis/
*.coverage
.coverage.*
# IDE and editor files
desktop.ini
.vscode/
.idea/
*.swp
@@ -77,25 +9,68 @@ instance/
*.bak
*.tmp
# OS-generated files
.DS_Store
Thumbs.db
desktop.ini
# Python
__pycache__/
*.py[cod]
*.pyc
*.pyo
*.pyd
*.pdb
*.egg-info/
*.eggs/
.pytest_cache/
*.mypy_cache/
*.hypothesis/
*.coverage
.coverage.*
*.cache
instance/
# Devcontainer-related
# Virtual environments
venv/
env/
.venv/
.env/
# Environment files
.env
.env.local
# Logs and databases
*.log
*.log.1
*.sqlite3
*.db
# Node.js
node_modules/
dashboard/node_modules/
dashboard/.vite/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-store/
# Docker
*.pid
*.tar
docker-compose.override.yml
docker-compose.override.*.yml
docker-compose.override.*.yaml
# Devcontainer
.devcontainer/
# Project-specific
received_screenshots/
mosquitto/
alte/
screenshots/
media/
mosquitto/
certs/
alte/
sync.ffs_db
dashboard/manitine_test.py
dashboard/pages/test.py
.gitignore
dashboard/sidebar_test.py
dashboard/assets/responsive-sidebar.css
certs/
sync.ffs_db
.pnpm-store/
dashboard/src/nested_tabs.js

View File

@@ -350,6 +350,10 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
## 🎨 Frontend Features
### API Response Format
- **JSON Convention**: All API endpoints return camelCase JSON (e.g., `startTime`, `endTime`, `groupId`). Frontend consumes camelCase directly.
- **UTC Time Parsing**: API returns ISO strings without 'Z' suffix. Frontend appends 'Z' before parsing to ensure UTC interpretation: `const utcString = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'; new Date(utcString);`. Display uses `toLocaleTimeString('de-DE')` for German format.
### Recurrence & holidays
- Recurrence is handled natively by Syncfusion. The API returns master events with `RecurrenceRule` and `RecurrenceException` (EXDATE) in RFC 5545 format (yyyyMMddTHHmmssZ, UTC) so the Scheduler excludes holiday instances reliably.
- Events with "skip holidays" display a TentTree icon next to the main event icon (icon color: black). The Schedulers native lower-right recurrence badge indicates series membership.
@@ -369,7 +373,14 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
- **SplitButtons**: Header user menu (top-right) using Syncfusion DropDownButton to show current user and role, with actions “Profil” and “Abmelden”.
### Pages Overview
- **Dashboard**: System overview and statistics
- **Dashboard**: Card-based overview of all Raumgruppen (room groups) with real-time status monitoring. Features include:
- Global statistics: total infoscreens, online/offline counts, warning groups
- Filter buttons: All / Online / Offline / Warnings with dynamic counts
- Per-group cards showing currently active event (title, type, date/time in local timezone)
- Health bar with online/offline ratio and color-coded status
- Expandable client list with last alive timestamps
- Bulk restart button for offline clients
- Auto-refresh every 15 seconds; manual refresh button available
- **Clients**: Device management and monitoring
- **Groups**: Client group organization
- **Events**: Schedule management

View File

@@ -7,7 +7,73 @@ This changelog documents technical and developer-relevant changes included in pu
---
## 2025.1.0-alpha.13 (2025-10-19)
## 2025.1.0-alpha.12 (2025-11-27)
- 🔄 **API Naming Convention Standardization**:
- Created `server/serializers.py` with `dict_to_camel_case()` and `dict_to_snake_case()` utilities for consistent JSON serialization
- Events API refactored: `GET /api/events` and `GET /api/events/<id>` now return camelCase JSON (`id`, `subject`, `startTime`, `endTime`, `type`, `groupId`, etc.) instead of PascalCase
- Internal event dictionaries use snake_case keys, then converted to camelCase via `dict_to_camel_case()` before `jsonify()`
- **Breaking**: External API consumers must update field names from PascalCase to camelCase
-**UTC Time Handling**:
- Standardized datetime handling: Database stores timestamps in UTC (naive timestamps normalized by backend)
- API returns ISO strings without 'Z' suffix: `"2025-11-27T20:03:00"`
- Frontend appends 'Z' to parse as UTC and displays in user's local timezone via `toLocaleTimeString('de-DE')`
- All time comparisons use UTC; `date.toISOString()` sends UTC back to API
- 🖥️ **Dashboard Major Redesign**:
- Completely redesigned dashboard with card-based layout for Raumgruppen (room groups)
- Global statistics summary card: total infoscreens, online/offline counts, warning groups
- Filter buttons with dynamic counts: All, Online, Offline, Warnings
- Active event display per group: shows currently playing content with type icon, title, date ("Heute"/"Morgen"/date), and time range
- Health visualization: color-coded progress bars showing online/offline ratio per group
- Expandable client details: shows last alive timestamps with human-readable format ("vor X Min.", "vor X Std.", "vor X Tagen")
- Bulk restart functionality: restart all offline clients in a group
- Manual refresh button with toast notifications
- 15-second auto-refresh interval
- "Nicht zugeordnet" group always appears last in sorted list
- 🎨 **Frontend Technical**:
- Dashboard (`dashboard/src/dashboard.tsx`): Uses Syncfusion ButtonComponent, ToastComponent, and card CSS classes
- Appointments page updated to map camelCase API responses to internal PascalCase for Syncfusion compatibility
- Time formatting functions (`formatEventTime`, `formatEventDate`) handle UTC string parsing with 'Z' appending
- TypeScript lint errors resolved: unused error variables removed, null safety checks added with optional chaining
- 📖 **Documentation**:
- Updated `.github/copilot-instructions.md` with comprehensive sections on:
- API patterns: JSON serialization, datetime handling conventions
- Frontend patterns: API response format, UTC time parsing
- Dashboard page overview with features
- Conventions & gotchas: datetime and JSON naming guidelines
- Updated `README.md` with recent changes, API response format section, and dashboard page details
Notes for integrators:
- **Breaking change**: All Events API endpoints now return camelCase field names. Update client code accordingly.
- Frontend must append 'Z' to API datetime strings before parsing: `const utcStr = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'; new Date(utcStr);`
- Use `dict_to_camel_case()` from `server/serializers.py` for any new API endpoints returning JSON
## 2025.1.0-alpha.11 (2025-11-05)
- 🗃️ Data model & API:
- Added `muted` (Boolean) to `Event` with Alembic migration; create/update and GET endpoints now accept, persist, and return `muted` alongside `autoplay`, `loop`, and `volume` for video events.
- Video event fields consolidated: `event_media_id`, `autoplay`, `loop`, `volume`, `muted`.
- 🔗 Streaming:
- Added range-capable streaming endpoint: `GET /api/eventmedia/stream/<media_id>/<filename>` (supports byte-range requests 206 for seeking).
- Scheduler: Performs a best-effort HEAD probe for video stream URLs and includes basic metadata in the emitted payload (`mime_type`, `size`, `accept_ranges`). Placeholders added for `duration`, `resolution`, `bitrate`, `qualities`, `thumbnails`, `checksum`.
- 🖥️ Frontend/Dashboard:
- Settings page refactored to nested tabs with controlled tab selection (`selectedItem`) to prevent sub-tab jumps.
- Settings → Events → Videos: Added system-wide defaults with load/save via system settings keys: `video_autoplay`, `video_loop`, `video_volume`, `video_muted`.
- Event modal (CustomEventModal): Exposes per-event video options including “Ton aus” (`muted`) and initializes all video fields from system defaults when creating new events.
- Academic Calendar (Settings): Merged “Schulferien Import” and “Liste” into a single sub-tab “📥 Import & Liste”.
- 📖 Documentation:
- Updated `README.md` and `.github/copilot-instructions.md` for video payload (incl. `muted`), streaming endpoint (206), nested Settings tabs, and video defaults keys; clarified client handling of `video` payloads.
- Updated `dashboard/public/program-info.json` (user-facing changelog) and bumped version to `2025.1.0-alpha.11` with corresponding UI/UX notes.
Notes for integrators:
- Clients should parse `event_type` and handle the nested `video` payload, honoring `autoplay`, `loop`, `volume`, and `muted`. Use the streaming endpoint with HTTP Range for seeking.
- System settings keys for video defaults: `video_autoplay`, `video_loop`, `video_volume`, `video_muted`.
## 2025.1.0-alpha.10 (2025-10-25)
- No new developer-facing changes in this release.
- UI/UX updates are documented in `dashboard/public/program-info.json`:
- Event modal: Surfaced video options (Autoplay, Loop, Volume).
- FileManager: Increased upload limits (Full-HD); client-side duration validation (max 10 minutes).
## 2025.1.0-alpha.9 (2025-10-19)
- 🗓️ Events/API:
- Implemented new `webuntis` event type. Event creation now resolves the URL from the system setting `supplement_table_url`; returns 400 if unset.
- Removed obsolete `webuntis-url` settings endpoints. Use `GET/POST /api/system-settings/supplement-table` for URL and enabled state (shared for WebUntis/Vertretungsplan).
@@ -29,7 +95,7 @@ Notes for integrators:
- Clients should now parse `event_type` and use the corresponding nested payload (`presentation`, `website`, …). `webuntis` and `website` should be handled identically (nested `website` payload).
## 2025.1.0-alpha.12 (2025-10-19)
## 2025.1.0-alpha.8 (2025-10-18)
- 🛠️ Backend: Seeded presentation defaults (`presentation_interval`, `presentation_page_progress`, `presentation_auto_progress`) in system settings; applied on event creation.
- 🗃️ Data model: Added `page_progress` and `auto_progress` fields to `Event` (with Alembic migration).
- 🗓️ Scheduler: Now publishes only currently active events per group (at "now"); clears retained topics by publishing `[]` for groups with no active events; normalizes naive timestamps and compares times in UTC; presentation payloads include `page_progress` and `auto_progress`.

24
dashboard/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,6 +1,6 @@
{
"appName": "Infoscreen-Management",
"version": "2025.1.0-alpha.11",
"version": "2025.1.0-alpha.12",
"copyright": "© 2025 Third-Age-Applications",
"supportContact": "support@third-age-applications.com",
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
@@ -26,10 +26,24 @@
]
},
"buildInfo": {
"buildDate": "2025-10-25T12:00:00Z",
"buildDate": "2025-11-27T12:00:00Z",
"commitId": "9f2ae8b44c3a"
},
"changelog": [
{
"version": "2025.1.0-alpha.12",
"date": "2025-11-27",
"changes": [
"✨ Dashboard: Komplett überarbeitetes Dashboard mit Karten-Design für alle Raumgruppen.",
"📊 Dashboard: Globale Statistik-Übersicht zeigt Gesamt-Infoscreens, Online/Offline-Anzahl und Warnungen.",
"🔍 Dashboard: Filter-Buttons (Alle, Online, Offline, Warnungen) mit dynamischen Zählern.",
"🎯 Dashboard: Anzeige des aktuell laufenden Events pro Gruppe (Titel, Typ, Datum, Uhrzeit in lokaler Zeitzone).",
"📈 Dashboard: Farbcodierte Health-Bars zeigen Online/Offline-Verhältnis je Gruppe.",
"👥 Dashboard: Ausklappbare Client-Details mit 'Zeit seit letztem Lebenszeichen' (z.B. 'vor 5 Min.').",
"🔄 Dashboard: Sammel-Neustart-Funktion für alle offline Clients einer Gruppe.",
"⏱️ Dashboard: Auto-Aktualisierung alle 15 Sekunden; manueller Aktualisierungs-Button verfügbar."
]
},
{
"version": "2025.1.0-alpha.11",
"date": "2025-11-05",

View File

@@ -369,11 +369,11 @@ const Appointments: React.FC = () => {
const expandedEvents: Event[] = [];
for (const e of data) {
if (e.RecurrenceRule) {
if (e.recurrenceRule) {
// Parse EXDATE list
const exdates = new Set<string>();
if (e.RecurrenceException) {
e.RecurrenceException.split(',').forEach((dateStr: string) => {
if (e.recurrenceException) {
e.recurrenceException.split(',').forEach((dateStr: string) => {
const trimmed = dateStr.trim();
exdates.add(trimmed);
});
@@ -381,53 +381,53 @@ const Appointments: React.FC = () => {
// Let Syncfusion handle ALL recurrence patterns natively for proper badge display
expandedEvents.push({
Id: e.Id,
Subject: e.Subject,
StartTime: parseEventDate(e.StartTime),
EndTime: parseEventDate(e.EndTime),
IsAllDay: e.IsAllDay,
MediaId: e.MediaId,
SlideshowInterval: e.SlideshowInterval,
PageProgress: e.PageProgress,
AutoProgress: e.AutoProgress,
WebsiteUrl: e.WebsiteUrl,
Autoplay: e.Autoplay,
Loop: e.Loop,
Volume: e.Volume,
Muted: e.Muted,
Icon: e.Icon,
Type: e.Type,
OccurrenceOfId: e.OccurrenceOfId,
Id: e.id,
Subject: e.subject,
StartTime: parseEventDate(e.startTime),
EndTime: parseEventDate(e.endTime),
IsAllDay: e.isAllDay,
MediaId: e.mediaId,
SlideshowInterval: e.slideshowInterval,
PageProgress: e.pageProgress,
AutoProgress: e.autoProgress,
WebsiteUrl: e.websiteUrl,
Autoplay: e.autoplay,
Loop: e.loop,
Volume: e.volume,
Muted: e.muted,
Icon: e.icon,
Type: e.type,
OccurrenceOfId: e.occurrenceOfId,
Recurrence: true,
RecurrenceRule: e.RecurrenceRule,
RecurrenceEnd: e.RecurrenceEnd ?? null,
SkipHolidays: e.SkipHolidays ?? false,
RecurrenceException: e.RecurrenceException || undefined,
RecurrenceRule: e.recurrenceRule,
RecurrenceEnd: e.recurrenceEnd ?? null,
SkipHolidays: e.skipHolidays ?? false,
RecurrenceException: e.recurrenceException || undefined,
});
} else {
// Non-recurring event - add as-is
expandedEvents.push({
Id: e.Id,
Subject: e.Subject,
StartTime: parseEventDate(e.StartTime),
EndTime: parseEventDate(e.EndTime),
IsAllDay: e.IsAllDay,
MediaId: e.MediaId,
SlideshowInterval: e.SlideshowInterval,
PageProgress: e.PageProgress,
AutoProgress: e.AutoProgress,
WebsiteUrl: e.WebsiteUrl,
Autoplay: e.Autoplay,
Loop: e.Loop,
Volume: e.Volume,
Muted: e.Muted,
Icon: e.Icon,
Type: e.Type,
OccurrenceOfId: e.OccurrenceOfId,
Id: e.id,
Subject: e.subject,
StartTime: parseEventDate(e.startTime),
EndTime: parseEventDate(e.endTime),
IsAllDay: e.isAllDay,
MediaId: e.mediaId,
SlideshowInterval: e.slideshowInterval,
PageProgress: e.pageProgress,
AutoProgress: e.autoProgress,
WebsiteUrl: e.websiteUrl,
Autoplay: e.autoplay,
Loop: e.loop,
Volume: e.volume,
Muted: e.muted,
Icon: e.icon,
Type: e.type,
OccurrenceOfId: e.occurrenceOfId,
Recurrence: false,
RecurrenceRule: null,
RecurrenceEnd: null,
SkipHolidays: e.SkipHolidays ?? false,
SkipHolidays: e.skipHolidays ?? false,
RecurrenceException: undefined,
});
}
@@ -512,7 +512,7 @@ const Appointments: React.FC = () => {
}, [holidays, allowScheduleOnHolidays]);
const dataSource = useMemo(() => {
// Filter: Events with SkipHolidays=true are never shown on holidays, regardless of toggle
// Filter: Events with SkipHolidays=true (from internal Event type) are never shown on holidays
const filteredEvents = events.filter(ev => {
if (ev.SkipHolidays) {
// If event falls within a holiday, hide it
@@ -912,10 +912,10 @@ const Appointments: React.FC = () => {
let isMasterRecurring = false;
try {
masterEvent = await fetchEventById(eventId);
isMasterRecurring = !!masterEvent.RecurrenceRule;
isMasterRecurring = !!masterEvent.recurrenceRule;
console.log('Master event info:', {
masterRecurrenceRule: masterEvent.RecurrenceRule,
masterStartTime: masterEvent.StartTime,
masterRecurrenceRule: masterEvent.recurrenceRule,
masterStartTime: masterEvent.startTime,
isMasterRecurring
});
} catch (err) {

View File

@@ -1,204 +1,787 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useState } from 'react';
import { fetchGroupsWithClients, restartClient } from './apiClients';
import type { Group, Client } from './apiClients';
import {
GridComponent,
ColumnsDirective,
ColumnDirective,
Page,
DetailRow,
Inject,
Sort,
} from '@syncfusion/ej2-react-grids';
import { fetchEvents } from './apiEvents';
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
import { ToastComponent } from '@syncfusion/ej2-react-notifications';
const REFRESH_INTERVAL = 15000; // 15 Sekunden
// Typ für Collapse-Event
// type DetailRowCollapseArgs = {
// data?: { id?: string | number };
// };
type FilterType = 'all' | 'online' | 'offline' | 'warning';
// Typ für DataBound-Event
type DetailRowDataBoundArgs = {
data?: { id?: string | number };
};
interface ActiveEvent {
id: string;
title: string;
event_type: string;
start: string;
end: string;
recurrenceRule?: string;
isRecurring: boolean;
}
interface GroupEvents {
[groupId: number]: ActiveEvent | null;
}
const Dashboard: React.FC = () => {
const [groups, setGroups] = useState<Group[]>([]);
const [expandedGroupIds, setExpandedGroupIds] = useState<string[]>([]);
const gridRef = useRef<GridComponent | null>(null);
// Funktion für das Schließen einer Gruppe (Collapse)
// const onDetailCollapse = (args: DetailRowCollapseArgs) => {
// if (args && args.data && args.data.id) {
// const groupId = String(args.data.id);
// setExpandedGroupIds(prev => prev.filter(id => String(id) !== groupId));
// }
// };
// // Registriere das Event nach dem Mount am Grid
// useEffect(() => {
// if (gridRef.current) {
// gridRef.current.detailCollapse = onDetailCollapse;
// }
// }, []);
const [expandedCards, setExpandedCards] = useState<Set<number>>(new Set());
const [filter, setFilter] = useState<FilterType>('all');
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
const [activeEvents, setActiveEvents] = useState<GroupEvents>({});
const toastRef = React.useRef<ToastComponent>(null);
// Optimiertes Update: Nur bei echten Datenänderungen wird das Grid aktualisiert
useEffect(() => {
let lastGroups: Group[] = [];
const fetchAndUpdate = async () => {
const newGroups = await fetchGroupsWithClients();
// Vergleiche nur die relevanten Felder (id, clients, is_alive)
const changed =
lastGroups.length !== newGroups.length ||
lastGroups.some((g, i) => {
const ng = newGroups[i];
if (!ng || g.id !== ng.id || g.clients.length !== ng.clients.length) return true;
// Optional: Vergleiche tiefer, z.B. Alive-Status
for (let j = 0; j < g.clients.length; j++) {
if (
g.clients[j].uuid !== ng.clients[j].uuid ||
g.clients[j].is_alive !== ng.clients[j].is_alive
) {
return true;
}
}
return false;
});
if (changed) {
setGroups(newGroups);
lastGroups = newGroups;
setTimeout(() => {
expandedGroupIds.forEach(id => {
const rowIndex = newGroups.findIndex(g => String(g.id) === String(id));
if (rowIndex !== -1 && gridRef.current) {
gridRef.current.detailRowModule.expand(rowIndex);
try {
const newGroups = await fetchGroupsWithClients();
// Vergleiche nur die relevanten Felder (id, clients, is_alive)
const changed =
lastGroups.length !== newGroups.length ||
lastGroups.some((g, i) => {
const ng = newGroups[i];
if (!ng || g.id !== ng.id || g.clients.length !== ng.clients.length) return true;
// Optional: Vergleiche tiefer, z.B. Alive-Status
for (let j = 0; j < g.clients.length; j++) {
if (
g.clients[j].uuid !== ng.clients[j].uuid ||
g.clients[j].is_alive !== ng.clients[j].is_alive
) {
return true;
}
}
return false;
});
}, 100);
if (changed) {
setGroups(newGroups);
lastGroups = newGroups;
setLastUpdate(new Date());
// Fetch active events for all groups
fetchActiveEventsForGroups(newGroups);
}
} catch (error) {
console.error('Fehler beim Laden der Gruppen:', error);
}
};
fetchAndUpdate();
const interval = setInterval(fetchAndUpdate, REFRESH_INTERVAL);
return () => clearInterval(interval);
}, [expandedGroupIds]);
}, []);
// Health-Badge
const getHealthBadge = (group: Group) => {
const total = group.clients.length;
const alive = group.clients.filter((c: Client) => c.is_alive).length;
const ratio = total === 0 ? 0 : alive / total;
let color = 'danger';
let text = `${alive} / ${total} offline`;
if (ratio === 1) {
color = 'success';
text = `${alive} / ${total} alive`;
} else if (ratio >= 0.5) {
color = 'warning';
text = `${alive} / ${total} teilw. alive`;
}
return <span className={`e-badge e-badge-${color}`}>{text}</span>;
};
// Einfache Tabelle für Clients einer Gruppe
const getClientTable = (group: Group) => (
<div style={{ maxHeight: 300, overflowY: 'auto', marginBottom: '5px' }}>
<GridComponent dataSource={group.clients} allowSorting={true} height={'auto'}>
<ColumnsDirective>
<ColumnDirective field="description" headerText="Beschreibung" width="150" />
<ColumnDirective field="ip" headerText="IP" width="120" />
{/* <ColumnDirective
field="last_alive"
headerText="Letztes Lebenszeichen"
width="180"
template={(props: { last_alive: string | null }) => {
if (!props.last_alive) return '-';
const dateStr = props.last_alive.endsWith('Z')
? props.last_alive
: props.last_alive + 'Z';
const date = new Date(dateStr);
return isNaN(date.getTime()) ? props.last_alive : date.toLocaleString();
}}
/> */}
<ColumnDirective
field="is_alive"
headerText="Alive"
width="100"
template={(props: { is_alive: boolean }) => (
<span className={`e-badge e-badge-${props.is_alive ? 'success' : 'danger'}`}>
{props.is_alive ? 'alive' : 'offline'}
</span>
)}
sortComparer={(a, b) => (a === b ? 0 : a ? -1 : 1)}
/>
<ColumnDirective
headerText="Aktionen"
width="150"
template={(props: { uuid: string }) => (
<button className="e-btn e-primary" onClick={() => handleRestartClient(props.uuid)}>
Neustart
</button>
)}
/>
</ColumnsDirective>
<Inject services={[Sort]} />
</GridComponent>
</div>
);
// Neustart-Logik
const handleRestartClient = async (uuid: string) => {
try {
const result = await restartClient(uuid);
alert(`Neustart erfolgreich: ${result.message}`);
} catch (error: unknown) {
if (error && typeof error === 'object' && 'message' in error) {
alert(`Fehler beim Neustart: ${(error as { message: string }).message}`);
} else {
alert('Unbekannter Fehler beim Neustart');
// Fetch currently active events for all groups
const fetchActiveEventsForGroups = async (groupsList: Group[]) => {
const now = new Date();
const eventsMap: GroupEvents = {};
for (const group of groupsList) {
try {
const events = await fetchEvents(String(group.id), false, {
start: new Date(now.getTime() - 60000), // 1 minute ago
end: new Date(now.getTime() + 60000), // 1 minute ahead
expand: true
});
// Find the first active event
if (events && events.length > 0) {
const activeEvent = events[0];
eventsMap[group.id] = {
id: activeEvent.id,
title: activeEvent.subject || 'Unbenannter Event',
event_type: activeEvent.type || 'unknown',
start: activeEvent.startTime, // Keep as string, will be parsed in format functions
end: activeEvent.endTime,
recurrenceRule: activeEvent.recurrenceRule,
isRecurring: !!activeEvent.recurrenceRule
};
} else {
eventsMap[group.id] = null;
}
} catch {
console.error(`Fehler beim Laden der Events für Gruppe ${group.id}`);
eventsMap[group.id] = null;
}
}
setActiveEvents(eventsMap);
};
// SyncFusion Grid liefert im Event die Zeile/Gruppe
const onDetailDataBound = (args: DetailRowDataBoundArgs) => {
if (args && args.data && args.data.id) {
const groupId = String(args.data.id);
setExpandedGroupIds(prev => (prev.includes(groupId) ? prev : [...prev, groupId]));
// Toggle card expansion
const toggleCard = (groupId: number) => {
setExpandedCards(prev => {
const newSet = new Set(prev);
if (newSet.has(groupId)) {
newSet.delete(groupId);
} else {
newSet.add(groupId);
}
return newSet;
});
};
// Health-Statistik berechnen
const getHealthStats = (group: Group) => {
const total = group.clients.length;
const alive = group.clients.filter((c: Client) => c.is_alive).length;
const offline = total - alive;
const ratio = total === 0 ? 0 : alive / total;
let statusColor = '#e74c3c'; // danger red
let statusText = 'Kritisch';
if (ratio === 1) {
statusColor = '#27ae60'; // success green
statusText = 'Optimal';
} else if (ratio >= 0.5) {
statusColor = '#f39c12'; // warning orange
statusText = 'Teilweise';
}
return { total, alive, offline, ratio, statusColor, statusText };
};
// Neustart-Logik
const handleRestartClient = async (uuid: string, description: string) => {
try {
const result = await restartClient(uuid);
toastRef.current?.show({
title: 'Neustart erfolgreich',
content: `${description || uuid}: ${result.message}`,
cssClass: 'e-toast-success',
icon: 'e-success toast-icons',
});
} catch (error: unknown) {
const message = error && typeof error === 'object' && 'message' in error
? (error as { message: string }).message
: 'Unbekannter Fehler beim Neustart';
toastRef.current?.show({
title: 'Fehler beim Neustart',
content: message,
cssClass: 'e-toast-danger',
icon: 'e-error toast-icons',
});
}
};
// Bulk restart für offline Clients einer Gruppe
const handleRestartAllOffline = async (group: Group) => {
const offlineClients = group.clients.filter(c => !c.is_alive);
if (offlineClients.length === 0) {
toastRef.current?.show({
title: 'Keine Offline-Geräte',
content: `Alle Infoscreens in "${group.name}" sind online.`,
cssClass: 'e-toast-info',
icon: 'e-info toast-icons',
});
return;
}
const confirmed = window.confirm(
`Möchten Sie ${offlineClients.length} offline Infoscreen(s) in "${group.name}" neu starten?`
);
if (!confirmed) return;
let successCount = 0;
let failCount = 0;
for (const client of offlineClients) {
try {
await restartClient(client.uuid);
successCount++;
} catch {
failCount++;
}
}
toastRef.current?.show({
title: 'Bulk-Neustart abgeschlossen',
content: `${successCount} erfolgreich, ✗ ${failCount} fehlgeschlagen`,
cssClass: failCount > 0 ? 'e-toast-warning' : 'e-toast-success',
icon: failCount > 0 ? 'e-warning toast-icons' : 'e-success toast-icons',
});
};
// Berechne Gesamtstatistiken
const getGlobalStats = () => {
const allClients = groups.flatMap(g => g.clients);
const total = allClients.length;
const online = allClients.filter(c => c.is_alive).length;
const offline = total - online;
const ratio = total === 0 ? 0 : online / total;
// Warnungen: Gruppen mit teilweise offline Clients
const warningGroups = groups.filter(g => {
const groupStats = getHealthStats(g);
return groupStats.ratio > 0 && groupStats.ratio < 1;
}).length;
return { total, online, offline, ratio, warningGroups };
};
// Berechne "Zeit seit letztem Lebenszeichen"
const getTimeSinceLastAlive = (lastAlive: string | null | undefined): string => {
if (!lastAlive) return 'Nie';
try {
const dateStr = lastAlive.endsWith('Z') ? lastAlive : lastAlive + 'Z';
const date = new Date(dateStr);
if (isNaN(date.getTime())) return 'Unbekannt';
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'Gerade eben';
if (diffMins < 60) return `vor ${diffMins} Min.`;
if (diffHours < 24) return `vor ${diffHours} Std.`;
return `vor ${diffDays} Tag${diffDays > 1 ? 'en' : ''}`;
} catch {
return 'Unbekannt';
}
};
// Manuelle Aktualisierung
const handleManualRefresh = async () => {
try {
const newGroups = await fetchGroupsWithClients();
setGroups(newGroups);
setLastUpdate(new Date());
await fetchActiveEventsForGroups(newGroups);
toastRef.current?.show({
title: 'Aktualisiert',
content: 'Daten wurden erfolgreich aktualisiert',
cssClass: 'e-toast-success',
icon: 'e-success toast-icons',
timeOut: 2000,
});
} catch {
toastRef.current?.show({
title: 'Fehler',
content: 'Daten konnten nicht aktualisiert werden',
cssClass: 'e-toast-danger',
icon: 'e-error toast-icons',
});
}
};
// Get event type icon
const getEventTypeIcon = (eventType: string): string => {
switch (eventType) {
case 'presentation': return '📊';
case 'website': return '🌐';
case 'webuntis': return '📅';
case 'video': return '🎬';
case 'message': return '💬';
default: return '📄';
}
};
// Get event type label
const getEventTypeLabel = (eventType: string): string => {
switch (eventType) {
case 'presentation': return 'Präsentation';
case 'website': return 'Website';
case 'webuntis': return 'WebUntis';
case 'video': return 'Video';
case 'message': return 'Nachricht';
default: return 'Inhalt';
}
};
// Format time for display
const formatEventTime = (dateStr: string): string => {
try {
// API returns UTC ISO strings without 'Z', so append 'Z' to parse as UTC
const utcString = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z';
const date = new Date(utcString);
if (isNaN(date.getTime())) return '—';
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
} catch {
return '—';
}
};
// Format date for display
const formatEventDate = (dateStr: string): string => {
try {
// API returns UTC ISO strings without 'Z', so append 'Z' to parse as UTC
const utcString = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z';
const date = new Date(utcString);
if (isNaN(date.getTime())) return '—';
const today = new Date();
const isToday = date.toDateString() === today.toDateString();
if (isToday) {
return 'Heute';
}
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
} catch {
return '—';
}
};
// Filter Gruppen basierend auf Status
const getFilteredGroups = () => {
return groups.filter(group => {
const stats = getHealthStats(group);
switch (filter) {
case 'online':
return stats.ratio === 1;
case 'offline':
return stats.ratio === 0;
case 'warning':
return stats.ratio > 0 && stats.ratio < 1;
default:
return true;
}
});
};
return (
<div>
<header style={{ marginBottom: 32, paddingBottom: 16, borderBottom: '2px solid #d6c3a6' }}>
<h2 style={{ fontSize: '1.75rem', fontWeight: 800, margin: 0, marginBottom: 8 }}>Dashboard</h2>
<ToastComponent
ref={toastRef}
position={{ X: 'Right', Y: 'Top' }}
timeOut={4000}
/>
<header style={{ marginBottom: 24, paddingBottom: 16, borderBottom: '2px solid #d6c3a6' }}>
<h2 style={{ fontSize: '1.75rem', fontWeight: 800, margin: 0, marginBottom: 8 }}>
Dashboard
</h2>
<p style={{ color: '#666', fontSize: '0.95rem', margin: 0 }}>
Übersicht aller Raumgruppen und deren Infoscreens
</p>
</header>
<h3 style={{ fontSize: '1.125rem', fontWeight: 600, marginTop: 24, marginBottom: 16 }}>
Raumgruppen Übersicht
</h3>
<GridComponent
dataSource={groups}
allowPaging={true}
pageSettings={{ pageSize: 5 }}
height={400}
detailTemplate={(props: Group) => getClientTable(props)}
detailDataBound={onDetailDataBound}
ref={gridRef}
>
<Inject services={[Page, DetailRow]} />
<ColumnsDirective>
<ColumnDirective field="name" headerText="Raumgruppe" width="180" />
<ColumnDirective
headerText="Health"
width="160"
template={(props: Group) => getHealthBadge(props)}
/>
</ColumnsDirective>
</GridComponent>
{groups.length === 0 && (
<div className="col-span-full text-center text-gray-400">Keine Gruppen gefunden.</div>
)}
{/* Global Statistics Summary */}
{(() => {
const globalStats = getGlobalStats();
return (
<div className="e-card" style={{
marginBottom: '24px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
borderRadius: '8px',
padding: '24px'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '20px',
alignItems: 'center'
}}>
<div>
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '4px' }}>
Gesamt Infoscreens
</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>
{globalStats.total}
</div>
</div>
<div>
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '4px' }}>
🟢 Online
</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>
{globalStats.online}
<span style={{ fontSize: '1rem', marginLeft: '8px', opacity: 0.8 }}>
({globalStats.total > 0 ? Math.round(globalStats.ratio * 100) : 0}%)
</span>
</div>
</div>
<div>
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '4px' }}>
🔴 Offline
</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>
{globalStats.offline}
</div>
</div>
<div>
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '4px' }}>
Gruppen mit Warnungen
</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>
{globalStats.warningGroups}
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '8px' }}>
Zuletzt aktualisiert: {getTimeSinceLastAlive(lastUpdate.toISOString())}
</div>
<ButtonComponent
cssClass="e-small e-info"
iconCss="e-icons e-refresh"
onClick={handleManualRefresh}
>
Aktualisieren
</ButtonComponent>
</div>
</div>
</div>
);
})()}
{/* Filter Buttons */}
<div style={{
display: 'flex',
gap: '12px',
marginBottom: '24px',
flexWrap: 'wrap'
}}>
<ButtonComponent
cssClass={filter === 'all' ? 'e-primary' : 'e-outline'}
onClick={() => setFilter('all')}
>
Alle anzeigen ({groups.length})
</ButtonComponent>
<ButtonComponent
cssClass={filter === 'online' ? 'e-success' : 'e-outline'}
onClick={() => setFilter('online')}
>
🟢 Nur Online ({groups.filter(g => getHealthStats(g).ratio === 1).length})
</ButtonComponent>
<ButtonComponent
cssClass={filter === 'offline' ? 'e-danger' : 'e-outline'}
onClick={() => setFilter('offline')}
>
🔴 Nur Offline ({groups.filter(g => getHealthStats(g).ratio === 0).length})
</ButtonComponent>
<ButtonComponent
cssClass={filter === 'warning' ? 'e-warning' : 'e-outline'}
onClick={() => setFilter('warning')}
>
Mit Warnungen ({groups.filter(g => {
const stats = getHealthStats(g);
return stats.ratio > 0 && stats.ratio < 1;
}).length})
</ButtonComponent>
</div>
{/* Group Cards */}
{(() => {
const filteredGroups = getFilteredGroups();
if (filteredGroups.length === 0) {
return (
<div className="e-card" style={{ padding: '40px', textAlign: 'center' }}>
<div style={{ color: '#999', fontSize: '1.1rem' }}>
{filter === 'all'
? 'Keine Raumgruppen gefunden'
: `Keine Gruppen mit Filter "${filter}" gefunden`
}
</div>
</div>
);
}
return (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))',
gap: '24px'
}}>
{filteredGroups
.sort((a, b) => {
// 'Nicht zugeordnet' always comes last
if (a.name === 'Nicht zugeordnet') return 1;
if (b.name === 'Nicht zugeordnet') return -1;
// Otherwise, sort alphabetically
return a.name.localeCompare(b.name);
})
.map((group) => {
const stats = getHealthStats(group);
const isExpanded = expandedCards.has(group.id);
return (
<div key={group.id} className="e-card" style={{
borderRadius: '8px',
overflow: 'hidden',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
transition: 'all 0.3s ease',
border: `2px solid ${stats.statusColor}20`
}}>
{/* Card Header */}
<div className="e-card-header" style={{
background: `linear-gradient(135deg, ${stats.statusColor}15, ${stats.statusColor}05)`,
borderBottom: `3px solid ${stats.statusColor}`,
padding: '16px 20px'
}}>
<div className="e-card-header-title" style={{
fontSize: '1.25rem',
fontWeight: '700',
color: '#333',
marginBottom: '8px'
}}>
{group.name}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
<span className={`e-badge e-badge-${stats.ratio === 1 ? 'success' : stats.ratio >= 0.5 ? 'warning' : 'danger'}`}>
{stats.statusText}
</span>
<span style={{ color: '#666', fontSize: '0.9rem' }}>
{stats.total} {stats.total === 1 ? 'Infoscreen' : 'Infoscreens'}
</span>
</div>
</div>
{/* Card Content - Statistics */}
<div className="e-card-content" style={{ padding: '20px' }}>
{/* Currently Active Event */}
{activeEvents[group.id] ? (
<div style={{
marginBottom: '16px',
padding: '12px',
backgroundColor: '#f0f7ff',
borderLeft: '4px solid #2196F3',
borderRadius: '4px'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px'
}}>
<div style={{
fontSize: '0.75rem',
color: '#666',
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}>
🎯 Aktuell angezeigt
</div>
{activeEvents[group.id]?.isRecurring && (
<span style={{
fontSize: '0.75rem',
backgroundColor: '#e3f2fd',
color: '#1976d2',
padding: '2px 8px',
borderRadius: '12px',
fontWeight: '600'
}}>
🔄 Wiederkehrend
</span>
)}
</div>
<div style={{
fontSize: '0.95rem',
fontWeight: '600',
color: '#333',
marginBottom: '6px',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}>
<span>{getEventTypeIcon(activeEvents[group.id]?.event_type || 'unknown')}</span>
<span style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1
}}>
{activeEvents[group.id]?.title || 'Unbenannter Event'}
</span>
</div>
<div style={{
fontSize: '0.8rem',
color: '#666',
marginBottom: '6px'
}}>
{getEventTypeLabel(activeEvents[group.id]?.event_type || 'unknown')}
</div>
<div style={{
fontSize: '0.8rem',
color: '#555',
display: 'flex',
alignItems: 'center',
gap: '8px',
flexWrap: 'wrap'
}}>
<span style={{ fontWeight: '500' }}>
📅 {activeEvents[group.id]?.start ? formatEventDate(activeEvents[group.id]!.start) : 'Kein Datum'}
</span>
<span></span>
<span>
🕐 {activeEvents[group.id]?.start && activeEvents[group.id]?.end
? `${formatEventTime(activeEvents[group.id]!.start)} - ${formatEventTime(activeEvents[group.id]!.end)}`
: 'Keine Zeit'}
</span>
</div>
</div>
) : (
<div style={{
marginBottom: '16px',
padding: '12px',
backgroundColor: '#f5f5f5',
borderLeft: '4px solid #999',
borderRadius: '4px'
}}>
<div style={{
fontSize: '0.85rem',
color: '#666',
fontStyle: 'italic'
}}>
📭 Kein aktiver Event
</div>
</div>
)}
{/* Health Bar */}
<div style={{ marginBottom: '20px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: '8px',
fontSize: '0.85rem',
color: '#666'
}}>
<span>🟢 Online: {stats.alive}</span>
<span>🔴 Offline: {stats.offline}</span>
</div>
<div style={{
height: '8px',
backgroundColor: '#e0e0e0',
borderRadius: '4px',
overflow: 'hidden'
}}>
<div style={{
height: '100%',
width: `${stats.ratio * 100}%`,
backgroundColor: stats.statusColor,
transition: 'width 0.5s ease'
}} />
</div>
</div>
{/* Action Buttons */}
<div style={{ display: 'flex', gap: '8px', marginBottom: isExpanded ? '16px' : '0' }}>
<ButtonComponent
cssClass="e-outline"
iconCss={isExpanded ? 'e-icons e-chevron-up' : 'e-icons e-chevron-down'}
onClick={() => toggleCard(group.id)}
style={{ flex: 1 }}
>
{isExpanded ? 'Details ausblenden' : 'Details anzeigen'}
</ButtonComponent>
{stats.offline > 0 && (
<ButtonComponent
cssClass="e-danger e-small"
iconCss="e-icons e-refresh"
onClick={() => handleRestartAllOffline(group)}
title={`${stats.offline} offline Gerät(e) neu starten`}
style={{ flexShrink: 0 }}
>
Offline neustarten
</ButtonComponent>
)}
</div>
{/* Expanded Client List */}
{isExpanded && (
<div style={{
marginTop: '16px',
maxHeight: '400px',
overflowY: 'auto',
border: '1px solid #e0e0e0',
borderRadius: '4px'
}}>
{group.clients.length === 0 ? (
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
Keine Infoscreens in dieser Gruppe
</div>
) : (
group.clients.map((client, index) => (
<div
key={client.uuid}
style={{
padding: '12px 16px',
borderBottom: index < group.clients.length - 1 ? '1px solid #f0f0f0' : 'none',
backgroundColor: client.is_alive ? '#f8fff9' : '#fff5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px'
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontWeight: '600',
fontSize: '0.95rem',
marginBottom: '4px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{client.description || client.hostname || 'Unbenannt'}
</div>
<div style={{
fontSize: '0.85rem',
color: '#666',
display: 'flex',
gap: '12px',
flexWrap: 'wrap'
}}>
<span>📍 {client.ip || 'Keine IP'}</span>
{client.hostname && (
<span title={client.hostname} style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '150px'
}}>
🖥 {client.hostname}
</span>
)}
</div>
<div style={{
fontSize: '0.75rem',
color: client.is_alive ? '#27ae60' : '#e74c3c',
marginTop: '4px',
fontWeight: '500'
}}>
{client.is_alive
? `✓ Aktiv ${getTimeSinceLastAlive(client.last_alive)}`
: `⚠ Offline seit ${getTimeSinceLastAlive(client.last_alive)}`
}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 }}>
<span className={`e-badge e-badge-${client.is_alive ? 'success' : 'danger'}`}>
{client.is_alive ? 'Online' : 'Offline'}
</span>
<ButtonComponent
cssClass="e-small e-primary"
iconCss="e-icons e-refresh"
onClick={() => handleRestartClient(client.uuid, client.description || client.hostname || 'Infoscreen')}
title="Neustart"
/>
</div>
</div>
))
)}
</div>
)}
</div>
</div>
);
})}
</div>
);
})()}
</div>
);
};

80
exclude.txt Normal file
View File

@@ -0,0 +1,80 @@
# OS/Editor
.DS_Store
Thumbs.db
desktop.ini
.vscode/
.idea/
*.swp
*.swo
*.bak
*.tmp
# Python
__pycache__/
*.py[cod]
*.pyc
*.pyo
*.pyd
*.pdb
*.egg-info/
*.eggs/
.pytest_cache/
*.mypy_cache/
*.hypothesis/
*.coverage
.coverage.*
*.cache
instance/
# Virtual environments
venv/
env/
.venv/
.env/
# Environment files
# .env
# .env.local
# Logs and databases
*.log
*.log.1
*.sqlite3
*.db
# Node.js
node_modules/
dashboard/node_modules/
dashboard/.vite/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-store/
# Docker
*.pid
*.tar
docker-compose.override.yml
docker-compose.override.*.yml
docker-compose.override.*.yaml
# Devcontainer
.devcontainer/
# Project-specific
received_screenshots/
screenshots/
media/
mosquitto/
certs/
alte/
sync.ffs_db
dashboard/manitine_test.py
dashboard/pages/test.py
dashboard/sidebar_test.py
dashboard/assets/responsive-sidebar.css
dashboard/src/nested_tabs.js
# Git
.git/
.gitignore

46
rsync-to-samba.sh Executable file
View File

@@ -0,0 +1,46 @@
#!/bin/bash
# Rsync to Samba share using permanent fstab mount
# Usage: ./rsync-to-samba.sh
set -euo pipefail
# Local source directory
SOURCE="./infoscreen_server_2025"
# Destination parent mount from fstab
DEST_PARENT="/mnt/nas_share"
DEST_SUBDIR="infoscreen_server_2025"
DEST_PATH="$DEST_PARENT/$DEST_SUBDIR"
# Exclude file (allows override via env)
EXCLUDE_FILE="${EXCLUDE_FILE:-exclude.txt}"
# Basic validations
if [ ! -d "$SOURCE" ]; then
echo "Source directory not found: $SOURCE" >&2
exit 1
fi
if [ ! -f "$EXCLUDE_FILE" ]; then
echo "Exclude file not found: $EXCLUDE_FILE (expected in repo root)." >&2
exit 1
fi
# Ensure the fstab-backed mount is active; don't unmount after sync
if ! mountpoint -q "$DEST_PARENT"; then
echo "Mount point $DEST_PARENT is not mounted. Attempting to mount via fstab..."
if ! sudo mount "$DEST_PARENT"; then
echo "Failed to mount $DEST_PARENT. Check your /etc/fstab entry and /root/.nas-credentials." >&2
exit 1
fi
fi
# Ensure destination directory exists
mkdir -p "$DEST_PATH"
echo "Syncing files to $DEST_PATH ..."
rsync -avz --progress \
--exclude-from="$EXCLUDE_FILE" \
"$SOURCE/" "$DEST_PATH/"
echo "Sync completed successfully."

View File

@@ -9,18 +9,19 @@ import datetime
import time
# Logging-Konfiguration
ENV = os.getenv("ENV", "development")
LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG" if ENV == "development" else "INFO")
from logging.handlers import RotatingFileHandler
LOG_PATH = os.path.join(os.path.dirname(__file__), "scheduler.log")
os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
log_handlers = []
if ENV == "production":
from logging.handlers import RotatingFileHandler
log_handlers.append(RotatingFileHandler(
LOG_PATH, maxBytes=2*1024*1024, backupCount=5, encoding="utf-8"))
else:
log_handlers.append(logging.FileHandler(LOG_PATH, encoding="utf-8"))
if os.getenv("DEBUG_MODE", "1" if ENV == "development" else "0") in ("1", "true", "True"):
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
log_handlers = [
RotatingFileHandler(
LOG_PATH,
maxBytes=10*1024*1024, # 10 MB
backupCount=2, # 1 current + 2 backups = 3 files total
encoding="utf-8"
)
]
if os.getenv("DEBUG_MODE", "0") in ("1", "true", "True"):
log_handlers.append(logging.StreamHandler())
logging.basicConfig(
level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),

View File

@@ -1,6 +1,7 @@
from flask import Blueprint, request, jsonify
from server.permissions import editor_or_higher
from server.database import Session
from server.serializers import dict_to_camel_case, dict_to_snake_case
from models.models import Event, EventMedia, MediaType, EventException, SystemSetting
from datetime import datetime, timezone, timedelta
from sqlalchemy import and_
@@ -95,28 +96,29 @@ def get_events():
recurrence_exception = ','.join(tokens)
base_payload = {
"Id": str(e.id),
"GroupId": e.group_id,
"Subject": e.title,
"Description": getattr(e, 'description', None),
"StartTime": e.start.isoformat() if e.start else None,
"EndTime": e.end.isoformat() if e.end else None,
"IsAllDay": False,
"MediaId": e.event_media_id,
"Type": e.event_type.value if e.event_type else None, # <-- Enum zu String!
"Icon": get_icon_for_type(e.event_type.value if e.event_type else None),
"id": str(e.id),
"group_id": e.group_id,
"subject": e.title,
"description": getattr(e, 'description', None),
"start_time": e.start.isoformat() if e.start else None,
"end_time": e.end.isoformat() if e.end else None,
"is_all_day": False,
"media_id": e.event_media_id,
"type": e.event_type.value if e.event_type else None,
"icon": get_icon_for_type(e.event_type.value if e.event_type else None),
# Recurrence metadata
"RecurrenceRule": e.recurrence_rule,
"RecurrenceEnd": e.recurrence_end.isoformat() if e.recurrence_end else None,
"RecurrenceException": recurrence_exception,
"SkipHolidays": bool(getattr(e, 'skip_holidays', False)),
"recurrence_rule": e.recurrence_rule,
"recurrence_end": e.recurrence_end.isoformat() if e.recurrence_end else None,
"recurrence_exception": recurrence_exception,
"skip_holidays": bool(getattr(e, 'skip_holidays', False)),
}
result.append(base_payload)
# No need to emit synthetic override events anymore since detached occurrences
# are now real Event rows that will be returned in the main query
session.close()
return jsonify(result)
# Convert all keys to camelCase for frontend
return jsonify(dict_to_camel_case(result))
@events_bp.route("/<event_id>", methods=["GET"]) # get single event
@@ -126,32 +128,32 @@ def get_event(event_id):
event = session.query(Event).filter_by(id=event_id).first()
if not event:
return jsonify({"error": "Termin nicht gefunden"}), 404
# Convert event to dictionary with all necessary fields
event_dict = {
"Id": str(event.id),
"Subject": event.title,
"StartTime": event.start.isoformat() if event.start else None,
"EndTime": event.end.isoformat() if event.end else None,
"Description": event.description,
"Type": event.event_type.value if event.event_type else "presentation",
"IsAllDay": False, # Assuming events are not all-day by default
"MediaId": str(event.event_media_id) if event.event_media_id else None,
"SlideshowInterval": event.slideshow_interval,
"PageProgress": event.page_progress,
"AutoProgress": event.auto_progress,
"WebsiteUrl": event.event_media.url if event.event_media and hasattr(event.event_media, 'url') else None,
"id": str(event.id),
"subject": event.title,
"start_time": event.start.isoformat() if event.start else None,
"end_time": event.end.isoformat() if event.end else None,
"description": event.description,
"type": event.event_type.value if event.event_type else "presentation",
"is_all_day": False, # Assuming events are not all-day by default
"media_id": str(event.event_media_id) if event.event_media_id else None,
"slideshow_interval": event.slideshow_interval,
"page_progress": event.page_progress,
"auto_progress": event.auto_progress,
"website_url": event.event_media.url if event.event_media and hasattr(event.event_media, 'url') else None,
# Video-specific fields
"Autoplay": event.autoplay,
"Loop": event.loop,
"Volume": event.volume,
"Muted": event.muted,
"RecurrenceRule": event.recurrence_rule,
"RecurrenceEnd": event.recurrence_end.isoformat() if event.recurrence_end else None,
"SkipHolidays": event.skip_holidays,
"Icon": get_icon_for_type(event.event_type.value if event.event_type else "presentation"),
"autoplay": event.autoplay,
"loop": event.loop,
"volume": event.volume,
"muted": event.muted,
"recurrence_rule": event.recurrence_rule,
"recurrence_end": event.recurrence_end.isoformat() if event.recurrence_end else None,
"skip_holidays": event.skip_holidays,
"icon": get_icon_for_type(event.event_type.value if event.event_type else "presentation"),
}
return jsonify(dict_to_camel_case(event_dict))
return jsonify(event_dict)
except Exception as e:
return jsonify({"error": f"Fehler beim Laden des Termins: {str(e)}"}), 500

74
server/serializers.py Normal file
View File

@@ -0,0 +1,74 @@
"""
Serialization helpers for converting between Python snake_case and JavaScript camelCase.
"""
import re
from typing import Any, Dict, List, Union
def to_camel_case(snake_str: str) -> str:
"""
Convert snake_case string to camelCase.
Examples:
event_type -> eventType
start_time -> startTime
is_active -> isActive
"""
components = snake_str.split('_')
# Keep the first component as-is, capitalize the rest
return components[0] + ''.join(word.capitalize() for word in components[1:])
def to_snake_case(camel_str: str) -> str:
"""
Convert camelCase string to snake_case.
Examples:
eventType -> event_type
startTime -> start_time
isActive -> is_active
"""
# Insert underscore before uppercase letters and convert to lowercase
snake = re.sub('([A-Z])', r'_\1', camel_str).lower()
# Remove leading underscore if present
return snake.lstrip('_')
def dict_to_camel_case(data: Union[Dict, List, Any]) -> Union[Dict, List, Any]:
"""
Recursively convert dictionary keys from snake_case to camelCase.
Also handles lists of dictionaries.
Args:
data: Dictionary, list, or primitive value to convert
Returns:
Converted data structure with camelCase keys
"""
if isinstance(data, dict):
return {to_camel_case(key): dict_to_camel_case(value)
for key, value in data.items()}
elif isinstance(data, list):
return [dict_to_camel_case(item) for item in data]
else:
return data
def dict_to_snake_case(data: Union[Dict, List, Any]) -> Union[Dict, List, Any]:
"""
Recursively convert dictionary keys from camelCase to snake_case.
Also handles lists of dictionaries.
Args:
data: Dictionary, list, or primitive value to convert
Returns:
Converted data structure with snake_case keys
"""
if isinstance(data, dict):
return {to_snake_case(key): dict_to_snake_case(value)
for key, value in data.items()}
elif isinstance(data, list):
return [dict_to_snake_case(item) for item in data]
else:
return data