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:
66
.github/copilot-instructions.md
vendored
66
.github/copilot-instructions.md
vendored
@@ -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 today’s 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 today’s 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
135
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -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 Scheduler’s 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 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”.
|
- **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
|
||||||
|
|||||||
@@ -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
24
dashboard/.gitignore
vendored
Normal 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?
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,52 +1,41 @@
|
|||||||
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 () => {
|
||||||
|
try {
|
||||||
const newGroups = await fetchGroupsWithClients();
|
const newGroups = await fetchGroupsWithClients();
|
||||||
// Vergleiche nur die relevanten Felder (id, clients, is_alive)
|
// Vergleiche nur die relevanten Felder (id, clients, is_alive)
|
||||||
const changed =
|
const changed =
|
||||||
@@ -68,138 +57,732 @@ const Dashboard: React.FC = () => {
|
|||||||
if (changed) {
|
if (changed) {
|
||||||
setGroups(newGroups);
|
setGroups(newGroups);
|
||||||
lastGroups = newGroups;
|
lastGroups = newGroups;
|
||||||
setTimeout(() => {
|
setLastUpdate(new Date());
|
||||||
expandedGroupIds.forEach(id => {
|
|
||||||
const rowIndex = newGroups.findIndex(g => String(g.id) === String(id));
|
// Fetch active events for all groups
|
||||||
if (rowIndex !== -1 && gridRef.current) {
|
fetchActiveEventsForGroups(newGroups);
|
||||||
gridRef.current.detailRowModule.expand(rowIndex);
|
|
||||||
}
|
}
|
||||||
});
|
} catch (error) {
|
||||||
}, 100);
|
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 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 total = group.clients.length;
|
||||||
const alive = group.clients.filter((c: Client) => c.is_alive).length;
|
const alive = group.clients.filter((c: Client) => c.is_alive).length;
|
||||||
|
const offline = total - alive;
|
||||||
const ratio = total === 0 ? 0 : alive / total;
|
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
|
let statusColor = '#e74c3c'; // danger red
|
||||||
const getClientTable = (group: Group) => (
|
let statusText = 'Kritisch';
|
||||||
<div style={{ maxHeight: 300, overflowY: 'auto', marginBottom: '5px' }}>
|
if (ratio === 1) {
|
||||||
<GridComponent dataSource={group.clients} allowSorting={true} height={'auto'}>
|
statusColor = '#27ae60'; // success green
|
||||||
<ColumnsDirective>
|
statusText = 'Optimal';
|
||||||
<ColumnDirective field="description" headerText="Beschreibung" width="150" />
|
} else if (ratio >= 0.5) {
|
||||||
<ColumnDirective field="ip" headerText="IP" width="120" />
|
statusColor = '#f39c12'; // warning orange
|
||||||
{/* <ColumnDirective
|
statusText = 'Teilweise';
|
||||||
field="last_alive"
|
}
|
||||||
headerText="Letztes Lebenszeichen"
|
|
||||||
width="180"
|
return { total, alive, offline, ratio, statusColor, statusText };
|
||||||
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
|
// Neustart-Logik
|
||||||
const handleRestartClient = async (uuid: string) => {
|
const handleRestartClient = async (uuid: string, description: string) => {
|
||||||
try {
|
try {
|
||||||
const result = await restartClient(uuid);
|
const result = await restartClient(uuid);
|
||||||
alert(`Neustart erfolgreich: ${result.message}`);
|
toastRef.current?.show({
|
||||||
|
title: 'Neustart erfolgreich',
|
||||||
|
content: `${description || uuid}: ${result.message}`,
|
||||||
|
cssClass: 'e-toast-success',
|
||||||
|
icon: 'e-success toast-icons',
|
||||||
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error && typeof error === 'object' && 'message' in error) {
|
const message = error && typeof error === 'object' && 'message' in error
|
||||||
alert(`Fehler beim Neustart: ${(error as { message: string }).message}`);
|
? (error as { message: string }).message
|
||||||
} else {
|
: 'Unbekannter Fehler beim Neustart';
|
||||||
alert('Unbekannter Fehler beim Neustart');
|
toastRef.current?.show({
|
||||||
}
|
title: 'Fehler beim Neustart',
|
||||||
|
content: message,
|
||||||
|
cssClass: 'e-toast-danger',
|
||||||
|
icon: 'e-error toast-icons',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// SyncFusion Grid liefert im Event die Zeile/Gruppe
|
// Bulk restart für offline Clients einer Gruppe
|
||||||
const onDetailDataBound = (args: DetailRowDataBoundArgs) => {
|
const handleRestartAllOffline = async (group: Group) => {
|
||||||
if (args && args.data && args.data.id) {
|
const offlineClients = group.clients.filter(c => !c.is_alive);
|
||||||
const groupId = String(args.data.id);
|
if (offlineClients.length === 0) {
|
||||||
setExpandedGroupIds(prev => (prev.includes(groupId) ? prev : [...prev, groupId]));
|
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}
|
||||||
</header>
|
position={{ X: 'Right', Y: 'Top' }}
|
||||||
<h3 style={{ fontSize: '1.125rem', fontWeight: 600, marginTop: 24, marginBottom: 16 }}>
|
timeOut={4000}
|
||||||
Raumgruppen Übersicht
|
|
||||||
</h3>
|
|
||||||
<GridComponent
|
|
||||||
dataSource={groups}
|
|
||||||
allowPaging={true}
|
|
||||||
pageSettings={{ pageSize: 5 }}
|
|
||||||
height={400}
|
|
||||||
detailTemplate={(props: Group) => getClientTable(props)}
|
|
||||||
detailDataBound={onDetailDataBound}
|
|
||||||
ref={gridRef}
|
|
||||||
>
|
|
||||||
<Inject services={[Page, DetailRow]} />
|
|
||||||
<ColumnsDirective>
|
|
||||||
<ColumnDirective field="name" headerText="Raumgruppe" width="180" />
|
|
||||||
<ColumnDirective
|
|
||||||
headerText="Health"
|
|
||||||
width="160"
|
|
||||||
template={(props: Group) => getHealthBadge(props)}
|
|
||||||
/>
|
/>
|
||||||
</ColumnsDirective>
|
|
||||||
</GridComponent>
|
<header style={{ marginBottom: 24, paddingBottom: 16, borderBottom: '2px solid #d6c3a6' }}>
|
||||||
{groups.length === 0 && (
|
<h2 style={{ fontSize: '1.75rem', fontWeight: 800, margin: 0, marginBottom: 8 }}>
|
||||||
<div className="col-span-full text-center text-gray-400">Keine Gruppen gefunden.</div>
|
Dashboard
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: '#666', fontSize: '0.95rem', margin: 0 }}>
|
||||||
|
Übersicht aller Raumgruppen und deren Infoscreens
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Global Statistics Summary */}
|
||||||
|
{(() => {
|
||||||
|
const globalStats = getGlobalStats();
|
||||||
|
return (
|
||||||
|
<div className="e-card" style={{
|
||||||
|
marginBottom: '24px',
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '24px'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||||
|
gap: '20px',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '4px' }}>
|
||||||
|
Gesamt Infoscreens
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>
|
||||||
|
{globalStats.total}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '4px' }}>
|
||||||
|
🟢 Online
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>
|
||||||
|
{globalStats.online}
|
||||||
|
<span style={{ fontSize: '1rem', marginLeft: '8px', opacity: 0.8 }}>
|
||||||
|
({globalStats.total > 0 ? Math.round(globalStats.ratio * 100) : 0}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '4px' }}>
|
||||||
|
🔴 Offline
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>
|
||||||
|
{globalStats.offline}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '4px' }}>
|
||||||
|
⚠️ Gruppen mit Warnungen
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>
|
||||||
|
{globalStats.warningGroups}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '8px' }}>
|
||||||
|
Zuletzt aktualisiert: {getTimeSinceLastAlive(lastUpdate.toISOString())}
|
||||||
|
</div>
|
||||||
|
<ButtonComponent
|
||||||
|
cssClass="e-small e-info"
|
||||||
|
iconCss="e-icons e-refresh"
|
||||||
|
onClick={handleManualRefresh}
|
||||||
|
>
|
||||||
|
Aktualisieren
|
||||||
|
</ButtonComponent>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Filter Buttons */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
}}>
|
||||||
|
<ButtonComponent
|
||||||
|
cssClass={filter === 'all' ? 'e-primary' : 'e-outline'}
|
||||||
|
onClick={() => setFilter('all')}
|
||||||
|
>
|
||||||
|
Alle anzeigen ({groups.length})
|
||||||
|
</ButtonComponent>
|
||||||
|
<ButtonComponent
|
||||||
|
cssClass={filter === 'online' ? 'e-success' : 'e-outline'}
|
||||||
|
onClick={() => setFilter('online')}
|
||||||
|
>
|
||||||
|
🟢 Nur Online ({groups.filter(g => getHealthStats(g).ratio === 1).length})
|
||||||
|
</ButtonComponent>
|
||||||
|
<ButtonComponent
|
||||||
|
cssClass={filter === 'offline' ? 'e-danger' : 'e-outline'}
|
||||||
|
onClick={() => setFilter('offline')}
|
||||||
|
>
|
||||||
|
🔴 Nur Offline ({groups.filter(g => getHealthStats(g).ratio === 0).length})
|
||||||
|
</ButtonComponent>
|
||||||
|
<ButtonComponent
|
||||||
|
cssClass={filter === 'warning' ? 'e-warning' : 'e-outline'}
|
||||||
|
onClick={() => setFilter('warning')}
|
||||||
|
>
|
||||||
|
⚠️ Mit Warnungen ({groups.filter(g => {
|
||||||
|
const stats = getHealthStats(g);
|
||||||
|
return stats.ratio > 0 && stats.ratio < 1;
|
||||||
|
}).length})
|
||||||
|
</ButtonComponent>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Group Cards */}
|
||||||
|
{(() => {
|
||||||
|
const filteredGroups = getFilteredGroups();
|
||||||
|
|
||||||
|
if (filteredGroups.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="e-card" style={{ padding: '40px', textAlign: 'center' }}>
|
||||||
|
<div style={{ color: '#999', fontSize: '1.1rem' }}>
|
||||||
|
{filter === 'all'
|
||||||
|
? 'Keine Raumgruppen gefunden'
|
||||||
|
: `Keine Gruppen mit Filter "${filter}" gefunden`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))',
|
||||||
|
gap: '24px'
|
||||||
|
}}>
|
||||||
|
{filteredGroups
|
||||||
|
.sort((a, b) => {
|
||||||
|
// 'Nicht zugeordnet' always comes last
|
||||||
|
if (a.name === 'Nicht zugeordnet') return 1;
|
||||||
|
if (b.name === 'Nicht zugeordnet') return -1;
|
||||||
|
// Otherwise, sort alphabetically
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
})
|
||||||
|
.map((group) => {
|
||||||
|
const stats = getHealthStats(group);
|
||||||
|
const isExpanded = expandedCards.has(group.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.id} className="e-card" style={{
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
border: `2px solid ${stats.statusColor}20`
|
||||||
|
}}>
|
||||||
|
{/* Card Header */}
|
||||||
|
<div className="e-card-header" style={{
|
||||||
|
background: `linear-gradient(135deg, ${stats.statusColor}15, ${stats.statusColor}05)`,
|
||||||
|
borderBottom: `3px solid ${stats.statusColor}`,
|
||||||
|
padding: '16px 20px'
|
||||||
|
}}>
|
||||||
|
<div className="e-card-header-title" style={{
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: '8px'
|
||||||
|
}}>
|
||||||
|
{group.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
|
||||||
|
<span className={`e-badge e-badge-${stats.ratio === 1 ? 'success' : stats.ratio >= 0.5 ? 'warning' : 'danger'}`}>
|
||||||
|
{stats.statusText}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: '#666', fontSize: '0.9rem' }}>
|
||||||
|
{stats.total} {stats.total === 1 ? 'Infoscreen' : 'Infoscreens'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Content - Statistics */}
|
||||||
|
<div className="e-card-content" style={{ padding: '20px' }}>
|
||||||
|
{/* Currently Active Event */}
|
||||||
|
{activeEvents[group.id] ? (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '16px',
|
||||||
|
padding: '12px',
|
||||||
|
backgroundColor: '#f0f7ff',
|
||||||
|
borderLeft: '4px solid #2196F3',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '8px'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: '#666',
|
||||||
|
fontWeight: '600',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.5px'
|
||||||
|
}}>
|
||||||
|
🎯 Aktuell angezeigt
|
||||||
|
</div>
|
||||||
|
{activeEvents[group.id]?.isRecurring && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
backgroundColor: '#e3f2fd',
|
||||||
|
color: '#1976d2',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontWeight: '600'
|
||||||
|
}}>
|
||||||
|
🔄 Wiederkehrend
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: '6px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px'
|
||||||
|
}}>
|
||||||
|
<span>{getEventTypeIcon(activeEvents[group.id]?.event_type || 'unknown')}</span>
|
||||||
|
<span style={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flex: 1
|
||||||
|
}}>
|
||||||
|
{activeEvents[group.id]?.title || 'Unbenannter Event'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: '#666',
|
||||||
|
marginBottom: '6px'
|
||||||
|
}}>
|
||||||
|
{getEventTypeLabel(activeEvents[group.id]?.event_type || 'unknown')}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: '#555',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
}}>
|
||||||
|
<span style={{ fontWeight: '500' }}>
|
||||||
|
📅 {activeEvents[group.id]?.start ? formatEventDate(activeEvents[group.id]!.start) : 'Kein Datum'}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
🕐 {activeEvents[group.id]?.start && activeEvents[group.id]?.end
|
||||||
|
? `${formatEventTime(activeEvents[group.id]!.start)} - ${formatEventTime(activeEvents[group.id]!.end)}`
|
||||||
|
: 'Keine Zeit'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '16px',
|
||||||
|
padding: '12px',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
borderLeft: '4px solid #999',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: '#666',
|
||||||
|
fontStyle: 'italic'
|
||||||
|
}}>
|
||||||
|
📭 Kein aktiver Event
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Health Bar */}
|
||||||
|
<div style={{ marginBottom: '20px' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: '8px',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: '#666'
|
||||||
|
}}>
|
||||||
|
<span>🟢 Online: {stats.alive}</span>
|
||||||
|
<span>🔴 Offline: {stats.offline}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
height: '8px',
|
||||||
|
backgroundColor: '#e0e0e0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${stats.ratio * 100}%`,
|
||||||
|
backgroundColor: stats.statusColor,
|
||||||
|
transition: 'width 0.5s ease'
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginBottom: isExpanded ? '16px' : '0' }}>
|
||||||
|
<ButtonComponent
|
||||||
|
cssClass="e-outline"
|
||||||
|
iconCss={isExpanded ? 'e-icons e-chevron-up' : 'e-icons e-chevron-down'}
|
||||||
|
onClick={() => toggleCard(group.id)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
{isExpanded ? 'Details ausblenden' : 'Details anzeigen'}
|
||||||
|
</ButtonComponent>
|
||||||
|
|
||||||
|
{stats.offline > 0 && (
|
||||||
|
<ButtonComponent
|
||||||
|
cssClass="e-danger e-small"
|
||||||
|
iconCss="e-icons e-refresh"
|
||||||
|
onClick={() => handleRestartAllOffline(group)}
|
||||||
|
title={`${stats.offline} offline Gerät(e) neu starten`}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
Offline neustarten
|
||||||
|
</ButtonComponent>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Client List */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '16px',
|
||||||
|
maxHeight: '400px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}>
|
||||||
|
{group.clients.length === 0 ? (
|
||||||
|
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
|
||||||
|
Keine Infoscreens in dieser Gruppe
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
group.clients.map((client, index) => (
|
||||||
|
<div
|
||||||
|
key={client.uuid}
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: index < group.clients.length - 1 ? '1px solid #f0f0f0' : 'none',
|
||||||
|
backgroundColor: client.is_alive ? '#f8fff9' : '#fff5f5',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '12px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
marginBottom: '4px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}>
|
||||||
|
{client.description || client.hostname || 'Unbenannt'}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: '#666',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px',
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
}}>
|
||||||
|
<span>📍 {client.ip || 'Keine IP'}</span>
|
||||||
|
{client.hostname && (
|
||||||
|
<span title={client.hostname} style={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
maxWidth: '150px'
|
||||||
|
}}>
|
||||||
|
🖥️ {client.hostname}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: client.is_alive ? '#27ae60' : '#e74c3c',
|
||||||
|
marginTop: '4px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}>
|
||||||
|
{client.is_alive
|
||||||
|
? `✓ Aktiv ${getTimeSinceLastAlive(client.last_alive)}`
|
||||||
|
: `⚠ Offline seit ${getTimeSinceLastAlive(client.last_alive)}`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 }}>
|
||||||
|
<span className={`e-badge e-badge-${client.is_alive ? 'success' : 'danger'}`}>
|
||||||
|
{client.is_alive ? 'Online' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
<ButtonComponent
|
||||||
|
cssClass="e-small e-primary"
|
||||||
|
iconCss="e-icons e-refresh"
|
||||||
|
onClick={() => handleRestartClient(client.uuid, client.description || client.hostname || 'Infoscreen')}
|
||||||
|
title="Neustart"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
80
exclude.txt
Normal file
80
exclude.txt
Normal 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
46
rsync-to-samba.sh
Executable 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."
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
74
server/serializers.py
Normal 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
|
||||||
Reference in New Issue
Block a user