From 6dcf93f0dd825e47ca5d2209fce65bdd5251d54f Mon Sep 17 00:00:00 2001 From: RobbStarkAustria <7694336+RobbStarkAustria@users.noreply.github.com> Date: Thu, 27 Nov 2025 20:30:00 +0000 Subject: [PATCH] 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. --- .github/copilot-instructions.md | 66 ++- .gitignore | 135 ++--- README.md | 13 +- TECH-CHANGELOG.md | 70 ++- dashboard/.gitignore | 24 + dashboard/public/program-info.json | 18 +- dashboard/src/appointments.tsx | 92 +-- dashboard/src/dashboard.tsx | 919 +++++++++++++++++++++++------ exclude.txt | 80 +++ rsync-to-samba.sh | 46 ++ scheduler/scheduler.py | 21 +- server/routes/events.py | 74 +-- server/serializers.py | 74 +++ 13 files changed, 1282 insertions(+), 350 deletions(-) create mode 100644 dashboard/.gitignore create mode 100644 exclude.txt create mode 100755 rsync-to-samba.sh create mode 100644 server/serializers.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2f92fdb..c800b71 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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/` 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//` 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 today’s 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`. diff --git a/.gitignore b/.gitignore index d25f60a..d0d9e50 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index b3430c7..f2a434b 100644 --- a/README.md +++ b/README.md @@ -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 Scheduler’s 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 diff --git a/TECH-CHANGELOG.md b/TECH-CHANGELOG.md index 80efb1f..f99f688 100644 --- a/TECH-CHANGELOG.md +++ b/TECH-CHANGELOG.md @@ -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/` 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//` (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`. diff --git a/dashboard/.gitignore b/dashboard/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/dashboard/.gitignore @@ -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? diff --git a/dashboard/public/program-info.json b/dashboard/public/program-info.json index 64ad736..5f8106a 100644 --- a/dashboard/public/program-info.json +++ b/dashboard/public/program-info.json @@ -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", diff --git a/dashboard/src/appointments.tsx b/dashboard/src/appointments.tsx index 1075666..10d612a 100644 --- a/dashboard/src/appointments.tsx +++ b/dashboard/src/appointments.tsx @@ -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(); - 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) { diff --git a/dashboard/src/dashboard.tsx b/dashboard/src/dashboard.tsx index 379349f..48cd898 100644 --- a/dashboard/src/dashboard.tsx +++ b/dashboard/src/dashboard.tsx @@ -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([]); - const [expandedGroupIds, setExpandedGroupIds] = useState([]); - const gridRef = useRef(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>(new Set()); + const [filter, setFilter] = useState('all'); + const [lastUpdate, setLastUpdate] = useState(new Date()); + const [activeEvents, setActiveEvents] = useState({}); + const toastRef = React.useRef(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 {text}; - }; - - // Einfache Tabelle für Clients einer Gruppe - const getClientTable = (group: Group) => ( -
- - - - - {/* { - 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(); - }} - /> */} - ( - - {props.is_alive ? 'alive' : 'offline'} - - )} - sortComparer={(a, b) => (a === b ? 0 : a ? -1 : 1)} - /> - ( - - )} - /> - - - -
- ); - - // 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 (
-
-

Dashboard

+ + +
+

+ Dashboard +

+

+ Übersicht aller Raumgruppen und deren Infoscreens +

-

- Raumgruppen Übersicht -

- getClientTable(props)} - detailDataBound={onDetailDataBound} - ref={gridRef} - > - - - - getHealthBadge(props)} - /> - - - {groups.length === 0 && ( -
Keine Gruppen gefunden.
- )} + + {/* Global Statistics Summary */} + {(() => { + const globalStats = getGlobalStats(); + return ( +
+
+
+
+ Gesamt Infoscreens +
+
+ {globalStats.total} +
+
+ +
+
+ 🟢 Online +
+
+ {globalStats.online} + + ({globalStats.total > 0 ? Math.round(globalStats.ratio * 100) : 0}%) + +
+
+ +
+
+ 🔴 Offline +
+
+ {globalStats.offline} +
+
+ +
+
+ ⚠️ Gruppen mit Warnungen +
+
+ {globalStats.warningGroups} +
+
+ +
+
+ Zuletzt aktualisiert: {getTimeSinceLastAlive(lastUpdate.toISOString())} +
+ + Aktualisieren + +
+
+
+ ); + })()} + + {/* Filter Buttons */} +
+ setFilter('all')} + > + Alle anzeigen ({groups.length}) + + setFilter('online')} + > + 🟢 Nur Online ({groups.filter(g => getHealthStats(g).ratio === 1).length}) + + setFilter('offline')} + > + 🔴 Nur Offline ({groups.filter(g => getHealthStats(g).ratio === 0).length}) + + setFilter('warning')} + > + ⚠️ Mit Warnungen ({groups.filter(g => { + const stats = getHealthStats(g); + return stats.ratio > 0 && stats.ratio < 1; + }).length}) + +
+ + {/* Group Cards */} + {(() => { + const filteredGroups = getFilteredGroups(); + + if (filteredGroups.length === 0) { + return ( +
+
+ {filter === 'all' + ? 'Keine Raumgruppen gefunden' + : `Keine Gruppen mit Filter "${filter}" gefunden` + } +
+
+ ); + } + + return ( +
+ {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 ( +
+ {/* Card Header */} +
+
+ {group.name} +
+
+ = 0.5 ? 'warning' : 'danger'}`}> + {stats.statusText} + + + {stats.total} {stats.total === 1 ? 'Infoscreen' : 'Infoscreens'} + +
+
+ + {/* Card Content - Statistics */} +
+ {/* Currently Active Event */} + {activeEvents[group.id] ? ( +
+
+
+ 🎯 Aktuell angezeigt +
+ {activeEvents[group.id]?.isRecurring && ( + + 🔄 Wiederkehrend + + )} +
+
+ {getEventTypeIcon(activeEvents[group.id]?.event_type || 'unknown')} + + {activeEvents[group.id]?.title || 'Unbenannter Event'} + +
+
+ {getEventTypeLabel(activeEvents[group.id]?.event_type || 'unknown')} +
+
+ + 📅 {activeEvents[group.id]?.start ? formatEventDate(activeEvents[group.id]!.start) : 'Kein Datum'} + + + + 🕐 {activeEvents[group.id]?.start && activeEvents[group.id]?.end + ? `${formatEventTime(activeEvents[group.id]!.start)} - ${formatEventTime(activeEvents[group.id]!.end)}` + : 'Keine Zeit'} + +
+
+ ) : ( +
+
+ 📭 Kein aktiver Event +
+
+ )} + + {/* Health Bar */} +
+
+ 🟢 Online: {stats.alive} + 🔴 Offline: {stats.offline} +
+
+
+
+
+ + {/* Action Buttons */} +
+ toggleCard(group.id)} + style={{ flex: 1 }} + > + {isExpanded ? 'Details ausblenden' : 'Details anzeigen'} + + + {stats.offline > 0 && ( + handleRestartAllOffline(group)} + title={`${stats.offline} offline Gerät(e) neu starten`} + style={{ flexShrink: 0 }} + > + Offline neustarten + + )} +
+ + {/* Expanded Client List */} + {isExpanded && ( +
+ {group.clients.length === 0 ? ( +
+ Keine Infoscreens in dieser Gruppe +
+ ) : ( + group.clients.map((client, index) => ( +
+
+
+ {client.description || client.hostname || 'Unbenannt'} +
+
+ 📍 {client.ip || 'Keine IP'} + {client.hostname && ( + + 🖥️ {client.hostname} + + )} +
+
+ {client.is_alive + ? `✓ Aktiv ${getTimeSinceLastAlive(client.last_alive)}` + : `⚠ Offline seit ${getTimeSinceLastAlive(client.last_alive)}` + } +
+
+
+ + {client.is_alive ? 'Online' : 'Offline'} + + handleRestartClient(client.uuid, client.description || client.hostname || 'Infoscreen')} + title="Neustart" + /> +
+
+ )) + )} +
+ )} +
+
+ ); + })} +
+ ); + })()}
); }; diff --git a/exclude.txt b/exclude.txt new file mode 100644 index 0000000..a6c69db --- /dev/null +++ b/exclude.txt @@ -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 diff --git a/rsync-to-samba.sh b/rsync-to-samba.sh new file mode 100755 index 0000000..4fb2c78 --- /dev/null +++ b/rsync-to-samba.sh @@ -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." diff --git a/scheduler/scheduler.py b/scheduler/scheduler.py index 0418cbb..9575b7e 100644 --- a/scheduler/scheduler.py +++ b/scheduler/scheduler.py @@ -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), diff --git a/server/routes/events.py b/server/routes/events.py index 396c13b..2b61d69 100644 --- a/server/routes/events.py +++ b/server/routes/events.py @@ -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("/", 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 diff --git a/server/serializers.py b/server/serializers.py new file mode 100644 index 0000000..c10c62e --- /dev/null +++ b/server/serializers.py @@ -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