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 ## 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. - 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. - 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. - 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`). - 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. - 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. - 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: - 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) - 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) - 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`. - 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. - 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: - User dropdown technical notes:
- Dependencies: `@syncfusion/ej2-react-splitbuttons` and `@syncfusion/ej2-splitbuttons` must be installed. - 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. - 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. - REFRESH_SECONDS — Optional scheduler republish interval; `0` disables periodic refresh.
## Conventions & gotchas ## Conventions & gotchas
- **Datetime Handling**:
- Always compare datetimes in UTC; some DB values may be naive—normalize before comparing (see `routes/events.py`). - 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). - 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`. - 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. - 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: - When adding a new route:
1) Create a Blueprint in `server/routes/...`, 1) Create a Blueprint in `server/routes/...`,
2) Register it in `server/wsgi.py`, 2) Register it in `server/wsgi.py`,
3) Manage `Session()` lifecycle, and 3) Manage `Session()` lifecycle,
4) Return JSON-safe values (serialize enums and datetimes). 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. - 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`). - 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 ### Recurrence & holidays: conventions
- Do not pre-expand recurrences on the backend. Always send master events with `RecurrenceRule` + `RecurrenceException`. - 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 # OS/Editor
.DS_Store .DS_Store
Thumbs.db Thumbs.db
.vscode/ desktop.ini
.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
.vscode/ .vscode/
.idea/ .idea/
*.swp *.swp
@@ -77,25 +9,68 @@ instance/
*.bak *.bak
*.tmp *.tmp
# OS-generated files # Python
.DS_Store __pycache__/
Thumbs.db *.py[cod]
desktop.ini *.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/ .devcontainer/
# Project-specific
received_screenshots/ received_screenshots/
mosquitto/
alte/
screenshots/ screenshots/
media/ media/
mosquitto/
certs/
alte/
sync.ffs_db
dashboard/manitine_test.py dashboard/manitine_test.py
dashboard/pages/test.py dashboard/pages/test.py
.gitignore
dashboard/sidebar_test.py dashboard/sidebar_test.py
dashboard/assets/responsive-sidebar.css dashboard/assets/responsive-sidebar.css
certs/
sync.ffs_db
.pnpm-store/
dashboard/src/nested_tabs.js dashboard/src/nested_tabs.js

View File

@@ -350,6 +350,10 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
## 🎨 Frontend Features ## 🎨 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 & 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. - 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. - 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”. - **SplitButtons**: Header user menu (top-right) using Syncfusion DropDownButton to show current user and role, with actions “Profil” and “Abmelden”.
### Pages Overview ### 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 - **Clients**: Device management and monitoring
- **Groups**: Client group organization - **Groups**: Client group organization
- **Events**: Schedule management - **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: - 🗓️ Events/API:
- Implemented new `webuntis` event type. Event creation now resolves the URL from the system setting `supplement_table_url`; returns 400 if unset. - 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). - 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). - 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. - 🛠️ 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). - 🗃️ 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`. - 🗓️ 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", "appName": "Infoscreen-Management",
"version": "2025.1.0-alpha.11", "version": "2025.1.0-alpha.12",
"copyright": "© 2025 Third-Age-Applications", "copyright": "© 2025 Third-Age-Applications",
"supportContact": "support@third-age-applications.com", "supportContact": "support@third-age-applications.com",
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.", "description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
@@ -26,10 +26,24 @@
] ]
}, },
"buildInfo": { "buildInfo": {
"buildDate": "2025-10-25T12:00:00Z", "buildDate": "2025-11-27T12:00:00Z",
"commitId": "9f2ae8b44c3a" "commitId": "9f2ae8b44c3a"
}, },
"changelog": [ "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", "version": "2025.1.0-alpha.11",
"date": "2025-11-05", "date": "2025-11-05",

View File

@@ -369,11 +369,11 @@ const Appointments: React.FC = () => {
const expandedEvents: Event[] = []; const expandedEvents: Event[] = [];
for (const e of data) { for (const e of data) {
if (e.RecurrenceRule) { if (e.recurrenceRule) {
// Parse EXDATE list // Parse EXDATE list
const exdates = new Set<string>(); const exdates = new Set<string>();
if (e.RecurrenceException) { if (e.recurrenceException) {
e.RecurrenceException.split(',').forEach((dateStr: string) => { e.recurrenceException.split(',').forEach((dateStr: string) => {
const trimmed = dateStr.trim(); const trimmed = dateStr.trim();
exdates.add(trimmed); exdates.add(trimmed);
}); });
@@ -381,53 +381,53 @@ const Appointments: React.FC = () => {
// Let Syncfusion handle ALL recurrence patterns natively for proper badge display // Let Syncfusion handle ALL recurrence patterns natively for proper badge display
expandedEvents.push({ expandedEvents.push({
Id: e.Id, Id: e.id,
Subject: e.Subject, Subject: e.subject,
StartTime: parseEventDate(e.StartTime), StartTime: parseEventDate(e.startTime),
EndTime: parseEventDate(e.EndTime), EndTime: parseEventDate(e.endTime),
IsAllDay: e.IsAllDay, IsAllDay: e.isAllDay,
MediaId: e.MediaId, MediaId: e.mediaId,
SlideshowInterval: e.SlideshowInterval, SlideshowInterval: e.slideshowInterval,
PageProgress: e.PageProgress, PageProgress: e.pageProgress,
AutoProgress: e.AutoProgress, AutoProgress: e.autoProgress,
WebsiteUrl: e.WebsiteUrl, WebsiteUrl: e.websiteUrl,
Autoplay: e.Autoplay, Autoplay: e.autoplay,
Loop: e.Loop, Loop: e.loop,
Volume: e.Volume, Volume: e.volume,
Muted: e.Muted, Muted: e.muted,
Icon: e.Icon, Icon: e.icon,
Type: e.Type, Type: e.type,
OccurrenceOfId: e.OccurrenceOfId, OccurrenceOfId: e.occurrenceOfId,
Recurrence: true, Recurrence: true,
RecurrenceRule: e.RecurrenceRule, RecurrenceRule: e.recurrenceRule,
RecurrenceEnd: e.RecurrenceEnd ?? null, RecurrenceEnd: e.recurrenceEnd ?? null,
SkipHolidays: e.SkipHolidays ?? false, SkipHolidays: e.skipHolidays ?? false,
RecurrenceException: e.RecurrenceException || undefined, RecurrenceException: e.recurrenceException || undefined,
}); });
} else { } else {
// Non-recurring event - add as-is // Non-recurring event - add as-is
expandedEvents.push({ expandedEvents.push({
Id: e.Id, Id: e.id,
Subject: e.Subject, Subject: e.subject,
StartTime: parseEventDate(e.StartTime), StartTime: parseEventDate(e.startTime),
EndTime: parseEventDate(e.EndTime), EndTime: parseEventDate(e.endTime),
IsAllDay: e.IsAllDay, IsAllDay: e.isAllDay,
MediaId: e.MediaId, MediaId: e.mediaId,
SlideshowInterval: e.SlideshowInterval, SlideshowInterval: e.slideshowInterval,
PageProgress: e.PageProgress, PageProgress: e.pageProgress,
AutoProgress: e.AutoProgress, AutoProgress: e.autoProgress,
WebsiteUrl: e.WebsiteUrl, WebsiteUrl: e.websiteUrl,
Autoplay: e.Autoplay, Autoplay: e.autoplay,
Loop: e.Loop, Loop: e.loop,
Volume: e.Volume, Volume: e.volume,
Muted: e.Muted, Muted: e.muted,
Icon: e.Icon, Icon: e.icon,
Type: e.Type, Type: e.type,
OccurrenceOfId: e.OccurrenceOfId, OccurrenceOfId: e.occurrenceOfId,
Recurrence: false, Recurrence: false,
RecurrenceRule: null, RecurrenceRule: null,
RecurrenceEnd: null, RecurrenceEnd: null,
SkipHolidays: e.SkipHolidays ?? false, SkipHolidays: e.skipHolidays ?? false,
RecurrenceException: undefined, RecurrenceException: undefined,
}); });
} }
@@ -512,7 +512,7 @@ const Appointments: React.FC = () => {
}, [holidays, allowScheduleOnHolidays]); }, [holidays, allowScheduleOnHolidays]);
const dataSource = useMemo(() => { 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 => { const filteredEvents = events.filter(ev => {
if (ev.SkipHolidays) { if (ev.SkipHolidays) {
// If event falls within a holiday, hide it // If event falls within a holiday, hide it
@@ -912,10 +912,10 @@ const Appointments: React.FC = () => {
let isMasterRecurring = false; let isMasterRecurring = false;
try { try {
masterEvent = await fetchEventById(eventId); masterEvent = await fetchEventById(eventId);
isMasterRecurring = !!masterEvent.RecurrenceRule; isMasterRecurring = !!masterEvent.recurrenceRule;
console.log('Master event info:', { console.log('Master event info:', {
masterRecurrenceRule: masterEvent.RecurrenceRule, masterRecurrenceRule: masterEvent.recurrenceRule,
masterStartTime: masterEvent.StartTime, masterStartTime: masterEvent.startTime,
isMasterRecurring isMasterRecurring
}); });
} catch (err) { } 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 { fetchGroupsWithClients, restartClient } from './apiClients';
import type { Group, Client } from './apiClients'; import type { Group, Client } from './apiClients';
import { import { fetchEvents } from './apiEvents';
GridComponent, import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
ColumnsDirective, import { ToastComponent } from '@syncfusion/ej2-react-notifications';
ColumnDirective,
Page,
DetailRow,
Inject,
Sort,
} from '@syncfusion/ej2-react-grids';
const REFRESH_INTERVAL = 15000; // 15 Sekunden const REFRESH_INTERVAL = 15000; // 15 Sekunden
// Typ für Collapse-Event type FilterType = 'all' | 'online' | 'offline' | 'warning';
// type DetailRowCollapseArgs = {
// data?: { id?: string | number };
// };
// Typ für DataBound-Event interface ActiveEvent {
type DetailRowDataBoundArgs = { id: string;
data?: { id?: string | number }; title: string;
}; event_type: string;
start: string;
end: string;
recurrenceRule?: string;
isRecurring: boolean;
}
interface GroupEvents {
[groupId: number]: ActiveEvent | null;
}
const Dashboard: React.FC = () => { const Dashboard: React.FC = () => {
const [groups, setGroups] = useState<Group[]>([]); const [groups, setGroups] = useState<Group[]>([]);
const [expandedGroupIds, setExpandedGroupIds] = useState<string[]>([]); const [expandedCards, setExpandedCards] = useState<Set<number>>(new Set());
const gridRef = useRef<GridComponent | null>(null); const [filter, setFilter] = useState<FilterType>('all');
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
// Funktion für das Schließen einer Gruppe (Collapse) const [activeEvents, setActiveEvents] = useState<GroupEvents>({});
// const onDetailCollapse = (args: DetailRowCollapseArgs) => { const toastRef = React.useRef<ToastComponent>(null);
// 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;
// }
// }, []);
// Optimiertes Update: Nur bei echten Datenänderungen wird das Grid aktualisiert // Optimiertes Update: Nur bei echten Datenänderungen wird das Grid aktualisiert
useEffect(() => { useEffect(() => {
let lastGroups: Group[] = []; let lastGroups: Group[] = [];
const fetchAndUpdate = async () => { const fetchAndUpdate = async () => {
const newGroups = await fetchGroupsWithClients(); try {
// Vergleiche nur die relevanten Felder (id, clients, is_alive) const newGroups = await fetchGroupsWithClients();
const changed = // Vergleiche nur die relevanten Felder (id, clients, is_alive)
lastGroups.length !== newGroups.length || const changed =
lastGroups.some((g, i) => { lastGroups.length !== newGroups.length ||
const ng = newGroups[i]; lastGroups.some((g, i) => {
if (!ng || g.id !== ng.id || g.clients.length !== ng.clients.length) return true; const ng = newGroups[i];
// Optional: Vergleiche tiefer, z.B. Alive-Status if (!ng || g.id !== ng.id || g.clients.length !== ng.clients.length) return true;
for (let j = 0; j < g.clients.length; j++) { // Optional: Vergleiche tiefer, z.B. Alive-Status
if ( for (let j = 0; j < g.clients.length; j++) {
g.clients[j].uuid !== ng.clients[j].uuid || if (
g.clients[j].is_alive !== ng.clients[j].is_alive g.clients[j].uuid !== ng.clients[j].uuid ||
) { g.clients[j].is_alive !== ng.clients[j].is_alive
return true; ) {
} 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);
} }
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(); fetchAndUpdate();
const interval = setInterval(fetchAndUpdate, REFRESH_INTERVAL); const interval = setInterval(fetchAndUpdate, REFRESH_INTERVAL);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [expandedGroupIds]); }, []);
// Health-Badge // Fetch currently active events for all groups
const getHealthBadge = (group: Group) => { const fetchActiveEventsForGroups = async (groupsList: Group[]) => {
const total = group.clients.length; const now = new Date();
const alive = group.clients.filter((c: Client) => c.is_alive).length; const eventsMap: GroupEvents = {};
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 for (const group of groupsList) {
const getClientTable = (group: Group) => ( try {
<div style={{ maxHeight: 300, overflowY: 'auto', marginBottom: '5px' }}> const events = await fetchEvents(String(group.id), false, {
<GridComponent dataSource={group.clients} allowSorting={true} height={'auto'}> start: new Date(now.getTime() - 60000), // 1 minute ago
<ColumnsDirective> end: new Date(now.getTime() + 60000), // 1 minute ahead
<ColumnDirective field="description" headerText="Beschreibung" width="150" /> expand: true
<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 // Find the first active event
const handleRestartClient = async (uuid: string) => { if (events && events.length > 0) {
try { const activeEvent = events[0];
const result = await restartClient(uuid);
alert(`Neustart erfolgreich: ${result.message}`); eventsMap[group.id] = {
} catch (error: unknown) { id: activeEvent.id,
if (error && typeof error === 'object' && 'message' in error) { title: activeEvent.subject || 'Unbenannter Event',
alert(`Fehler beim Neustart: ${(error as { message: string }).message}`); event_type: activeEvent.type || 'unknown',
} else { start: activeEvent.startTime, // Keep as string, will be parsed in format functions
alert('Unbekannter Fehler beim Neustart'); 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 // Toggle card expansion
const onDetailDataBound = (args: DetailRowDataBoundArgs) => { const toggleCard = (groupId: number) => {
if (args && args.data && args.data.id) { setExpandedCards(prev => {
const groupId = String(args.data.id); const newSet = new Set(prev);
setExpandedGroupIds(prev => (prev.includes(groupId) ? prev : [...prev, groupId])); 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 ( return (
<div> <div>
<header style={{ marginBottom: 32, paddingBottom: 16, borderBottom: '2px solid #d6c3a6' }}> <ToastComponent
<h2 style={{ fontSize: '1.75rem', fontWeight: 800, margin: 0, marginBottom: 8 }}>Dashboard</h2> 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> </header>
<h3 style={{ fontSize: '1.125rem', fontWeight: 600, marginTop: 24, marginBottom: 16 }}>
Raumgruppen Übersicht {/* Global Statistics Summary */}
</h3> {(() => {
<GridComponent const globalStats = getGlobalStats();
dataSource={groups} return (
allowPaging={true} <div className="e-card" style={{
pageSettings={{ pageSize: 5 }} marginBottom: '24px',
height={400} background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
detailTemplate={(props: Group) => getClientTable(props)} color: 'white',
detailDataBound={onDetailDataBound} borderRadius: '8px',
ref={gridRef} padding: '24px'
> }}>
<Inject services={[Page, DetailRow]} /> <div style={{
<ColumnsDirective> display: 'grid',
<ColumnDirective field="name" headerText="Raumgruppe" width="180" /> gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
<ColumnDirective gap: '20px',
headerText="Health" alignItems: 'center'
width="160" }}>
template={(props: Group) => getHealthBadge(props)} <div>
/> <div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '4px' }}>
</ColumnsDirective> Gesamt Infoscreens
</GridComponent> </div>
{groups.length === 0 && ( <div style={{ fontSize: '2rem', fontWeight: 'bold' }}>
<div className="col-span-full text-center text-gray-400">Keine Gruppen gefunden.</div> {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> </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 import time
# Logging-Konfiguration # Logging-Konfiguration
ENV = os.getenv("ENV", "development") from logging.handlers import RotatingFileHandler
LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG" if ENV == "development" else "INFO")
LOG_PATH = os.path.join(os.path.dirname(__file__), "scheduler.log") LOG_PATH = os.path.join(os.path.dirname(__file__), "scheduler.log")
os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True) os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
log_handlers = [] LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
if ENV == "production": log_handlers = [
from logging.handlers import RotatingFileHandler RotatingFileHandler(
log_handlers.append(RotatingFileHandler( LOG_PATH,
LOG_PATH, maxBytes=2*1024*1024, backupCount=5, encoding="utf-8")) maxBytes=10*1024*1024, # 10 MB
else: backupCount=2, # 1 current + 2 backups = 3 files total
log_handlers.append(logging.FileHandler(LOG_PATH, encoding="utf-8")) encoding="utf-8"
if os.getenv("DEBUG_MODE", "1" if ENV == "development" else "0") in ("1", "true", "True"): )
]
if os.getenv("DEBUG_MODE", "0") in ("1", "true", "True"):
log_handlers.append(logging.StreamHandler()) log_handlers.append(logging.StreamHandler())
logging.basicConfig( logging.basicConfig(
level=getattr(logging, LOG_LEVEL.upper(), logging.INFO), level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),

View File

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