Compare commits
72 Commits
dash-front
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 03e3c11e90 | |||
| 4d652f0554 | |||
| 06411edfab | |||
| 365d8f58f3 | |||
| 3fc7d33e43 | |||
| b5f5f30005 | |||
| 2580aa5e0d | |||
| a58e9d3fca | |||
| 90ccbdf920 | |||
| 24cdf07279 | |||
| 9c330f984f | |||
| 3107d0f671 | |||
|
|
7746e26385 | ||
|
|
10f446dfb5 | ||
|
|
5a0c1bc686 | ||
|
|
c193209326 | ||
|
|
df9f29bc6a | ||
|
|
6dcf93f0dd | ||
|
|
452ba3033b | ||
|
|
38800cec68 | ||
|
|
e6c19c189f | ||
|
|
c9cc535fc6 | ||
|
|
3487d33a2f | ||
|
|
150937f2e2 | ||
|
|
7b38b49598 | ||
|
|
a7df3c2708 | ||
|
|
8676370fe2 | ||
|
|
5f0972c79c | ||
|
|
17c3452310 | ||
|
|
e53cc619ec | ||
|
|
773628c324 | ||
|
|
7ab4ea14c4 | ||
|
|
4d807be6f8 | ||
|
|
0601bac243 | ||
|
|
4a97ad4f1d | ||
|
|
1efe40a03b | ||
| 5627829617 | |||
| fc9b3228c4 | |||
| fcc0dfbb0f | |||
| 80bf8bc58d | |||
| eaf6e32446 | |||
| 41194000a4 | |||
| 89d1748100 | |||
| c19f478f11 | |||
| e8d71b8349 | |||
| c5a8571e97 | |||
| 1d23b7591d | |||
| f3b72da9fe | |||
| 75c5622efe | |||
| 4c44b98d53 | |||
| 76629b8e30 | |||
| 86b1bdbd91 | |||
| e30723da0a | |||
| 4e74f72c9f | |||
| 2ca5f0060e | |||
| 270bad5980 | |||
| 49e9f9eade | |||
| 8bbda836b3 | |||
| 4e6451ce80 | |||
| b0e933e895 | |||
| 7f4800496a | |||
| c0202e5802 | |||
| c9fbb38347 | |||
| 2e9f22f5cc | |||
| a1d6d83488 | |||
| 4e525e4bae | |||
| 1a6faaa104 | |||
| 84a92ab9c2 | |||
| f37744b31e | |||
| 661d25d70c | |||
| 2fa84c1e2b | |||
| 7c1f546af9 |
61
.env.example
Normal file
61
.env.example
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Copy this file to .env and fill in values as needed for local development.
|
||||||
|
# NOTE: No secrets should be committed. Use placeholders below.
|
||||||
|
|
||||||
|
# General
|
||||||
|
ENV=development
|
||||||
|
|
||||||
|
# Flask
|
||||||
|
# IMPORTANT: Generate a secure random key for production
|
||||||
|
# e.g., python -c 'import secrets; print(secrets.token_hex(32))'
|
||||||
|
FLASK_SECRET_KEY=dev-secret-key-change-in-production
|
||||||
|
|
||||||
|
# Database (used if DB_CONN not provided)
|
||||||
|
DB_USER=your_user
|
||||||
|
DB_PASSWORD=your_password
|
||||||
|
DB_NAME=infoscreen_by_taa
|
||||||
|
DB_HOST=db
|
||||||
|
# Preferred connection string for services (overrides the above if set)
|
||||||
|
# DB_CONN=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}/${DB_NAME}
|
||||||
|
|
||||||
|
# MQTT
|
||||||
|
MQTT_BROKER_HOST=mqtt
|
||||||
|
MQTT_BROKER_PORT=1883
|
||||||
|
# Required for authenticated broker access
|
||||||
|
MQTT_USER=your_mqtt_user
|
||||||
|
MQTT_PASSWORD=replace_with_a_32plus_char_random_password
|
||||||
|
# Optional: dedicated canary client account
|
||||||
|
MQTT_CANARY_USER=your_canary_mqtt_user
|
||||||
|
MQTT_CANARY_PASSWORD=replace_with_a_different_32plus_char_random_password
|
||||||
|
# Optional TLS settings
|
||||||
|
MQTT_TLS_ENABLED=false
|
||||||
|
MQTT_TLS_CA_CERT=
|
||||||
|
MQTT_TLS_CERTFILE=
|
||||||
|
MQTT_TLS_KEYFILE=
|
||||||
|
MQTT_TLS_INSECURE=false
|
||||||
|
MQTT_KEEPALIVE=60
|
||||||
|
|
||||||
|
# Dashboard
|
||||||
|
# Used when building the production dashboard image
|
||||||
|
# VITE_API_URL=https://your.api.example.com/api
|
||||||
|
|
||||||
|
# Groups alive windows (seconds)
|
||||||
|
# Clients send heartbeats every ~65s. Allow 2 missed heartbeats + safety margin
|
||||||
|
# Dev: 65s * 2 + 50s margin = 180s
|
||||||
|
# Prod: 65s * 2 + 40s margin = 170s
|
||||||
|
HEARTBEAT_GRACE_PERIOD_DEV=180
|
||||||
|
HEARTBEAT_GRACE_PERIOD_PROD=170
|
||||||
|
|
||||||
|
# Scheduler
|
||||||
|
# Optional: force periodic republish even without changes
|
||||||
|
# REFRESH_SECONDS=0
|
||||||
|
|
||||||
|
# Crash recovery (scheduler auto-recovery)
|
||||||
|
# CRASH_RECOVERY_ENABLED=false
|
||||||
|
# CRASH_RECOVERY_GRACE_SECONDS=180
|
||||||
|
# CRASH_RECOVERY_LOCKOUT_MINUTES=15
|
||||||
|
# CRASH_RECOVERY_COMMAND_EXPIRY_SECONDS=240
|
||||||
|
|
||||||
|
# Default superadmin bootstrap (server/init_defaults.py)
|
||||||
|
# REQUIRED: Must be set for superadmin creation
|
||||||
|
DEFAULT_SUPERADMIN_USERNAME=superadmin
|
||||||
|
DEFAULT_SUPERADMIN_PASSWORD=your_secure_password_here
|
||||||
5
.github/FRONTEND_DESIGN_RULES.md
vendored
Normal file
5
.github/FRONTEND_DESIGN_RULES.md
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# FRONTEND Design Rules
|
||||||
|
|
||||||
|
Canonical source: [../FRONTEND_DESIGN_RULES.md](../FRONTEND_DESIGN_RULES.md)
|
||||||
|
|
||||||
|
Use the repository-root file as the maintained source of truth.
|
||||||
113
.github/copilot-instructions.md
vendored
Normal file
113
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Copilot instructions for infoscreen_2025
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
This file is a concise, high-signal brief for coding agents.
|
||||||
|
It is not a changelog and not a full architecture handbook.
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
- Stack: Flask API + MariaDB, React/Vite dashboard, MQTT listener, scheduler, worker.
|
||||||
|
- Main areas:
|
||||||
|
- API logic in `server/`
|
||||||
|
- Scheduler logic in `scheduler/`
|
||||||
|
- UI logic in `dashboard/src/`
|
||||||
|
- Keep changes minimal, match existing patterns, and update docs in the same commit when behavior changes.
|
||||||
|
|
||||||
|
## Fast file map
|
||||||
|
- `scheduler/scheduler.py` - scheduler loop, MQTT event publishing, TV power intent publishing, crash auto-recovery, command expiry sweep
|
||||||
|
- `scheduler/db_utils.py` - event formatting, power-intent helpers, crash recovery helpers, command expiry sweep
|
||||||
|
- `listener/listener.py` - discovery/heartbeat/log/screenshot/service_failed MQTT consumption
|
||||||
|
- `server/init_academic_periods.py` - idempotent academic-period seeding + auto-activation for current date
|
||||||
|
- `server/initialize_database.py` - migration + bootstrap orchestration for local/manual setup
|
||||||
|
- `server/routes/events.py` - event CRUD, recurrence handling, UTC normalization
|
||||||
|
- `server/routes/eventmedia.py` - file manager, media upload/stream endpoints
|
||||||
|
- `server/routes/groups.py` - group lifecycle, alive status, order persistence
|
||||||
|
- `server/routes/system_settings.py` - system settings CRUD and supplement-table endpoint
|
||||||
|
- `server/routes/clients.py` - client metadata, restart/shutdown/restart_app command issuing, command status, crashed/service_failed alert endpoints
|
||||||
|
- `dashboard/src/settings.tsx` - settings UX and system-defaults integration
|
||||||
|
- `dashboard/src/components/CustomEventModal.tsx` - event creation/editing UX
|
||||||
|
- `dashboard/src/monitoring.tsx` - superadmin monitoring page
|
||||||
|
- `TV_POWER_INTENT_SERVER_CONTRACT_V1.md` - Phase 1 TV power contract
|
||||||
|
|
||||||
|
## Service picture
|
||||||
|
- API: `server/` on `:8000` (health: `/health`)
|
||||||
|
- Dashboard: `dashboard/` (dev `:5173`, proxied API calls)
|
||||||
|
- MQTT broker: Mosquitto (`mosquitto/config/mosquitto.conf`)
|
||||||
|
- Listener: MQTT consumer that updates server-side state
|
||||||
|
- Scheduler: publishes active events and group-level TV power intents
|
||||||
|
- Nginx: routes `/api/*` and `/screenshots/*` to API, dashboard otherwise
|
||||||
|
- Prod bootstrap: `docker-compose.prod.yml` server command runs migrations, defaults init, and academic-period init before Gunicorn start
|
||||||
|
|
||||||
|
## Non-negotiable conventions
|
||||||
|
- Datetime:
|
||||||
|
- Store/compare in UTC.
|
||||||
|
- API returns ISO strings without `Z` in many routes.
|
||||||
|
- Frontend must append `Z` before parsing if needed.
|
||||||
|
- JSON naming:
|
||||||
|
- Backend internals use snake_case.
|
||||||
|
- API responses use camelCase (via `server/serializers.py`).
|
||||||
|
- DB host in containers: `db` (not localhost).
|
||||||
|
- Never put secrets in docs.
|
||||||
|
|
||||||
|
## MQTT contracts
|
||||||
|
- Event list topic (retained): `infoscreen/events/{group_id}`
|
||||||
|
- Group assignment topic (retained): `infoscreen/{uuid}/group_id`
|
||||||
|
- Heartbeat topic: `infoscreen/{uuid}/heartbeat`
|
||||||
|
- Logs topic family: `infoscreen/{uuid}/logs/{error|warn|info}`
|
||||||
|
- Health topic: `infoscreen/{uuid}/health`
|
||||||
|
- Dashboard screenshot topic: `infoscreen/{uuid}/dashboard`
|
||||||
|
- Client command topic (QoS1, non-retained): `infoscreen/{uuid}/commands` (compat alias: `infoscreen/{uuid}/command`)
|
||||||
|
- Client command ack topic (QoS1, non-retained): `infoscreen/{uuid}/commands/ack` (compat alias: `infoscreen/{uuid}/command/ack`)
|
||||||
|
- Service-failed topic (retained, client→server): `infoscreen/{uuid}/service_failed`
|
||||||
|
- TV power intent Phase 1 topic (retained, QoS1): `infoscreen/groups/{group_id}/power/intent`
|
||||||
|
|
||||||
|
TV power intent Phase 1 rules:
|
||||||
|
- Schema version is `"1.0"`.
|
||||||
|
- Group-only scope in Phase 1.
|
||||||
|
- Heartbeat publish keeps `intent_id`; semantic transition rotates `intent_id`.
|
||||||
|
- Expiry rule: `expires_at = issued_at + max(3 x poll_interval_sec, 90s)`.
|
||||||
|
- Canonical contract is `TV_POWER_INTENT_SERVER_CONTRACT_V1.md`.
|
||||||
|
|
||||||
|
## Backend patterns
|
||||||
|
- Routes in `server/routes/*`, registered in `server/wsgi.py`.
|
||||||
|
- Use one request-scoped DB session, commit on mutation, always close session.
|
||||||
|
- Keep enum/datetime serialization JSON-safe.
|
||||||
|
- Maintain UTC-safe comparisons in scheduler and routes.
|
||||||
|
- Keep recurrence handling backend-driven and consistent with exceptions.
|
||||||
|
- Academic periods bootstrap is idempotent and should auto-activate period covering `date.today()` when available.
|
||||||
|
|
||||||
|
## Frontend patterns
|
||||||
|
- Use Syncfusion-based patterns already present in dashboard.
|
||||||
|
- Keep API requests relative (`/api/...`) to use Vite proxy in dev.
|
||||||
|
- Respect `FRONTEND_DESIGN_RULES.md` for component and styling conventions.
|
||||||
|
- Keep role-gated UI behavior aligned with backend authorization.
|
||||||
|
- Holiday status banner in dashboard should render from computed state and avoid stale message reuse in 3rd-party UI components.
|
||||||
|
|
||||||
|
## Environment variables (high-value)
|
||||||
|
- Scheduler: `POLL_INTERVAL_SECONDS`, `REFRESH_SECONDS`
|
||||||
|
- Power intent: `POWER_INTENT_PUBLISH_ENABLED`, `POWER_INTENT_HEARTBEAT_ENABLED`, `POWER_INTENT_EXPIRY_MULTIPLIER`, `POWER_INTENT_MIN_EXPIRY_SECONDS`
|
||||||
|
- Monitoring: `PRIORITY_SCREENSHOT_TTL_SECONDS`
|
||||||
|
- Crash recovery: `CRASH_RECOVERY_ENABLED`, `CRASH_RECOVERY_GRACE_SECONDS`, `CRASH_RECOVERY_LOCKOUT_MINUTES`, `CRASH_RECOVERY_COMMAND_EXPIRY_SECONDS`
|
||||||
|
- Core: `DB_CONN`, `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_NAME`, `ENV`
|
||||||
|
- MQTT auth/connectivity: `MQTT_BROKER_HOST`, `MQTT_BROKER_PORT`, `MQTT_USER`, `MQTT_PASSWORD` (listener/scheduler/server should use authenticated broker access)
|
||||||
|
|
||||||
|
## Edit guardrails
|
||||||
|
- Do not edit generated assets in `dashboard/dist/`.
|
||||||
|
- Do not change CI/build outputs unless explicitly intended.
|
||||||
|
- Preserve existing API behavior unless task explicitly requires a change.
|
||||||
|
- Prefer links to canonical docs instead of embedding long historical notes here.
|
||||||
|
|
||||||
|
## Documentation sync rule
|
||||||
|
When services/MQTT/API/UTC/env behavior changes:
|
||||||
|
1. Update this file (concise deltas only).
|
||||||
|
2. Update canonical docs where details live.
|
||||||
|
3. Update changelogs separately (`TECH-CHANGELOG.md`, `DEV-CHANGELOG.md`, `dashboard/public/program-info.json` as appropriate).
|
||||||
|
|
||||||
|
## Canonical docs map
|
||||||
|
- Repo entry: `README.md`
|
||||||
|
- Instruction governance: `AI-INSTRUCTIONS-MAINTENANCE.md`
|
||||||
|
- Technical release details: `TECH-CHANGELOG.md`
|
||||||
|
- Workspace/development notes: `DEV-CHANGELOG.md`
|
||||||
|
- MQTT payload details: `MQTT_EVENT_PAYLOAD_GUIDE.md`
|
||||||
|
- TV power contract: `TV_POWER_INTENT_SERVER_CONTRACT_V1.md`
|
||||||
|
- Frontend patterns: `FRONTEND_DESIGN_RULES.md`
|
||||||
|
- Archived historical docs: `docs/archive/`
|
||||||
120
.gitignore
vendored
120
.gitignore
vendored
@@ -1,53 +1,7 @@
|
|||||||
# Python-related
|
# OS/Editor
|
||||||
__pycache__/
|
.DS_Store
|
||||||
*.py[cod]
|
Thumbs.db
|
||||||
*.pyo
|
desktop.ini
|
||||||
*.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
|
||||||
@@ -55,23 +9,69 @@ 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/
|
dashboard/src/nested_tabs.js
|
||||||
sync.ffs_db
|
scheduler/scheduler.log.2
|
||||||
|
|||||||
126
AI-INSTRUCTIONS-MAINTENANCE.md
Normal file
126
AI-INSTRUCTIONS-MAINTENANCE.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# Maintaining AI Assistant Instructions (copilot-instructions.md)
|
||||||
|
|
||||||
|
This repo uses `.github/copilot-instructions.md` to brief AI coding agents about your architecture, workflows, and conventions. Keep it concise, repo-specific, and always in sync with your code.
|
||||||
|
|
||||||
|
This guide explains when and how to update it, plus small guardrails to help—even for a solo developer.
|
||||||
|
|
||||||
|
## When to update
|
||||||
|
Update the instructions in the same commit as your change whenever you:
|
||||||
|
- Add/rename services, ports, or container wiring (docker-compose*.yml, Nginx, Mosquitto)
|
||||||
|
- Introduce/rename MQTT topics or change retained-message behavior
|
||||||
|
- Add/rename environment variables or change defaults (`.env.example`, `deployment.md`)
|
||||||
|
- Change DB models or time/UTC handling (e.g., `models/models.py`, UTC normalization in routes/scheduler)
|
||||||
|
- Add/modify API route patterns or session lifecycle (files in `server/routes/*`, `server/wsgi.py`)
|
||||||
|
- Adjust frontend dev proxy or build settings (`dashboard/vite.config.ts`, Dockerfiles)
|
||||||
|
- Modify scheduler polling, power-intent semantics, or retention strategy
|
||||||
|
|
||||||
|
## What to update (and where)
|
||||||
|
- `.github/copilot-instructions.md`
|
||||||
|
- Big picture: services and ports
|
||||||
|
- Service boundaries & data flow: DB connection rules, MQTT topics, retained messages, screenshots
|
||||||
|
- API patterns: Blueprints, Session per request, enum/datetime serialization
|
||||||
|
- Frontend patterns: Vite dev proxy and pre-bundled dependencies
|
||||||
|
- Environment variables (reference): names, purposes, example patterns
|
||||||
|
- Conventions & gotchas: UTC comparisons, retained MQTT, container hostnames
|
||||||
|
- `.env.example`
|
||||||
|
- Add new variable names with placeholders and comments (never secrets)
|
||||||
|
- Keep in-container defaults (e.g., `DB_HOST=db`, `MQTT_BROKER_HOST=mqtt`)
|
||||||
|
- `deployment.md`
|
||||||
|
- Update Quickstart URLs/ports/commands
|
||||||
|
- Document prod-specific env usage (e.g., `VITE_API_URL`, `DB_CONN`)
|
||||||
|
|
||||||
|
## How to write good updates
|
||||||
|
- Keep it short (approx. 20–50 lines total). Link to code by path or route rather than long prose.
|
||||||
|
- Document real, present patterns—not plans.
|
||||||
|
- Use UTC consistently and call out any special handling.
|
||||||
|
- Include concrete examples from this repo when describing patterns (e.g., which route shows enum serialization).
|
||||||
|
- Never include secrets or real tokens; show only variable names and example formats.
|
||||||
|
|
||||||
|
## Scope boundaries (important)
|
||||||
|
To avoid turning `.github/copilot-instructions.md` into a shadow README/changelog, keep clear boundaries:
|
||||||
|
- `.github/copilot-instructions.md`: quick operational brief for agents (architecture snapshot, non-negotiable conventions, key paths, critical contracts).
|
||||||
|
- `README.md`: project entrypoint and documentation navigation.
|
||||||
|
- `TECH-CHANGELOG.md` and `DEV-CHANGELOG.md`: change history and release/development notes.
|
||||||
|
- Feature contracts/specs: dedicated files (for example `MQTT_EVENT_PAYLOAD_GUIDE.md`, `TV_POWER_INTENT_SERVER_CONTRACT_V1.md`).
|
||||||
|
|
||||||
|
Do not place long historical sections, release summaries, or full endpoint catalogs in `.github/copilot-instructions.md`.
|
||||||
|
|
||||||
|
## Size and quality guardrails
|
||||||
|
- Target size for `.github/copilot-instructions.md`: about 120-220 lines.
|
||||||
|
- If a new section exceeds ~10 lines, prefer linking to an existing canonical doc instead.
|
||||||
|
- Keep each section focused on actionability for coding agents.
|
||||||
|
- Remove duplicate rules if already stated in another section.
|
||||||
|
- Use concise bullets over long prose blocks.
|
||||||
|
|
||||||
|
Quick pre-commit check:
|
||||||
|
- Is this content a rule/pattern needed during coding now?
|
||||||
|
- If it is historical context, move it to changelog/archive docs.
|
||||||
|
- If it is deep reference material, move it to the canonical feature doc and link it.
|
||||||
|
|
||||||
|
## Solo-friendly workflow
|
||||||
|
- Update docs in the same commit as your change:
|
||||||
|
- Code changed → docs changed (copilot-instructions, `.env.example`, `deployment.md` as needed)
|
||||||
|
- Use a quick self-checklist before pushing:
|
||||||
|
- Services/ports changed? Update “Big picture”.
|
||||||
|
- MQTT topics/retained behavior changed? Update “Service boundaries & data flow”.
|
||||||
|
- API/Session/UTC rules changed? Update “API patterns” and “Conventions & gotchas”.
|
||||||
|
- Frontend proxy/build changed? Update “Frontend patterns”.
|
||||||
|
- Env vars changed? Update “Environment variables (reference)” + `.env.example`.
|
||||||
|
- Dev/prod run steps changed? Update `deployment.md` Quickstart.
|
||||||
|
- Keep commits readable by pairing code and doc changes:
|
||||||
|
- `feat(api): add events endpoint; docs: update routes and UTC note`
|
||||||
|
- `chore(compose): rename service; docs: update ports + nginx`
|
||||||
|
- `docs(env): add MQTT_USER to .env.example + instructions`
|
||||||
|
|
||||||
|
## Optional guardrails (even for solo)
|
||||||
|
- PR (or MR) template (useful even if you self-merge)
|
||||||
|
- Add `.github/pull_request_template.md` with:
|
||||||
|
```
|
||||||
|
Checklist
|
||||||
|
- [ ] Updated .github/copilot-instructions.md (services/MQTT/API/UTC/env)
|
||||||
|
- [ ] Synced .env.example (new/renamed vars)
|
||||||
|
- [ ] Adjusted deployment.md (dev/prod steps, URLs/ports)
|
||||||
|
- [ ] Verified referenced files/paths in the instructions exist
|
||||||
|
```
|
||||||
|
- Lightweight docs check (optional pre-commit hook)
|
||||||
|
- Non-blocking script that warns if referenced files/paths don’t exist. Example sketch:
|
||||||
|
```
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
FILE=.github/copilot-instructions.md
|
||||||
|
missing=0
|
||||||
|
for path in \
|
||||||
|
server/wsgi.py \
|
||||||
|
server/routes/clients.py \
|
||||||
|
server/routes/events.py \
|
||||||
|
server/routes/groups.py \
|
||||||
|
dashboard/vite.config.ts \
|
||||||
|
docker-compose.yml \
|
||||||
|
docker-compose.override.yml; do
|
||||||
|
if ! test -e "$path"; then
|
||||||
|
echo "[warn] referenced path not found: $path"; missing=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exit 0 # warn only; do not block commit
|
||||||
|
```
|
||||||
|
- Weekly 2-minute sweep
|
||||||
|
- Read `.github/copilot-instructions.md` top-to-bottom and remove anything stale.
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
- Where do the AI assistants look?
|
||||||
|
- `.github/copilot-instructions.md` + the code you have open. Keep this file synced with the codebase.
|
||||||
|
- Is it safe to commit this file?
|
||||||
|
- Yes—no secrets. It should contain only structure, patterns, and example formats.
|
||||||
|
- How detailed should it be?
|
||||||
|
- Concise and actionable; point to exact files for details. Avoid generic advice.
|
||||||
|
|
||||||
|
## Pointers to key files
|
||||||
|
- Compose & infra: `docker-compose*.yml`, `nginx.conf`, `mosquitto/config/mosquitto.conf`
|
||||||
|
- Backend: `server/database.py`, `server/wsgi.py`, `server/routes/*`, `models/models.py`
|
||||||
|
- MQTT workers: `listener/listener.py`, `scheduler/scheduler.py`, `server/mqtt_helper.py`
|
||||||
|
- Frontend: `dashboard/vite.config.ts`, `dashboard/package.json`, `dashboard/src/*`
|
||||||
|
- Dev/Prod docs: `deployment.md`, `.env.example`
|
||||||
|
|
||||||
|
## Documentation sync log
|
||||||
|
- 2026-03-24: Synced docs for completed monitoring rollout and presentation flag persistence fix (`page_progress` / `auto_progress`). Updated `.github/copilot-instructions.md`, `README.md`, `TECH-CHANGELOG.md`, `DEV-CHANGELOG.md`, and `docs/archive/CLIENT_MONITORING_IMPLEMENTATION_GUIDE.md` without a user-version bump.
|
||||||
|
- 2026-04-01: Synced docs for TV power intent Phase 1 implementation and contract consistency. Updated `.github/copilot-instructions.md`, `MQTT_EVENT_PAYLOAD_GUIDE.md`, `docs/archive/TV_POWER_PHASE_1_SERVER_HANDOFF.md`, `TECH-CHANGELOG.md`, and `DEV-CHANGELOG.md` to match scheduler behavior (`infoscreen/groups/{group_id}/power/intent`, `schema_version: "1.0"`, transition + heartbeat semantics, poll-based expiry).
|
||||||
264
AUTH_QUICKREF.md
Normal file
264
AUTH_QUICKREF.md
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
# Authentication Quick Reference
|
||||||
|
|
||||||
|
## For Backend Developers
|
||||||
|
|
||||||
|
### Protecting a Route
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flask import Blueprint
|
||||||
|
from server.permissions import require_role, admin_or_higher, editor_or_higher
|
||||||
|
|
||||||
|
my_bp = Blueprint("myroute", __name__, url_prefix="/api/myroute")
|
||||||
|
|
||||||
|
# Specific role(s)
|
||||||
|
@my_bp.route("/admin")
|
||||||
|
@require_role('admin', 'superadmin')
|
||||||
|
def admin_only():
|
||||||
|
return {"message": "Admin only"}
|
||||||
|
|
||||||
|
# Convenience decorators
|
||||||
|
@my_bp.route("/settings")
|
||||||
|
@admin_or_higher
|
||||||
|
def settings():
|
||||||
|
return {"message": "Admin or superadmin"}
|
||||||
|
|
||||||
|
@my_bp.route("/create", methods=["POST"])
|
||||||
|
@editor_or_higher
|
||||||
|
def create():
|
||||||
|
return {"message": "Editor, admin, or superadmin"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getting Current User in Route
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flask import session
|
||||||
|
|
||||||
|
@my_bp.route("/profile")
|
||||||
|
@require_auth
|
||||||
|
def profile():
|
||||||
|
user_id = session.get('user_id')
|
||||||
|
username = session.get('username')
|
||||||
|
role = session.get('role')
|
||||||
|
return {
|
||||||
|
"user_id": user_id,
|
||||||
|
"username": username,
|
||||||
|
"role": role
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## For Frontend Developers
|
||||||
|
|
||||||
|
### Using the Auth Hook
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useAuth } from './useAuth';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { user, isAuthenticated, login, logout, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) return <div>Loading...</div>;
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <button onClick={() => login('user', 'pass')}>Login</button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>Welcome {user?.username}</p>
|
||||||
|
<p>Role: {user?.role}</p>
|
||||||
|
<button onClick={logout}>Logout</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Rendering
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useCurrentUser } from './useAuth';
|
||||||
|
import { isAdminOrHigher, isEditorOrHigher } from './apiAuth';
|
||||||
|
|
||||||
|
function Navigation() {
|
||||||
|
const user = useCurrentUser();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav>
|
||||||
|
<a href="/">Home</a>
|
||||||
|
|
||||||
|
{/* Show for all authenticated users */}
|
||||||
|
{user && <a href="/events">Events</a>}
|
||||||
|
|
||||||
|
{/* Show for editor+ */}
|
||||||
|
{isEditorOrHigher(user) && (
|
||||||
|
<a href="/events/new">Create Event</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show for admin+ */}
|
||||||
|
{isAdminOrHigher(user) && (
|
||||||
|
<a href="/admin">Admin Panel</a>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Making Authenticated API Calls
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Always include credentials for session cookies
|
||||||
|
const response = await fetch('/api/protected-route', {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
// ... other options
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Role Hierarchy
|
||||||
|
|
||||||
|
```
|
||||||
|
superadmin > admin > editor > user
|
||||||
|
```
|
||||||
|
|
||||||
|
| Role | Can Do |
|
||||||
|
|------|--------|
|
||||||
|
| **user** | View events |
|
||||||
|
| **editor** | user + CRUD events/media |
|
||||||
|
| **admin** | editor + manage users/groups/settings |
|
||||||
|
| **superadmin** | admin + manage superadmins + system config |
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required for sessions
|
||||||
|
FLASK_SECRET_KEY=your_secret_key_here
|
||||||
|
|
||||||
|
# Required for superadmin creation
|
||||||
|
DEFAULT_SUPERADMIN_USERNAME=superadmin
|
||||||
|
DEFAULT_SUPERADMIN_PASSWORD=your_password_here
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate a secret key:
|
||||||
|
```bash
|
||||||
|
python -c 'import secrets; print(secrets.token_hex(32))'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Endpoints
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Login
|
||||||
|
curl -X POST http://localhost:8000/api/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"superadmin","password":"your_password"}' \
|
||||||
|
-c cookies.txt
|
||||||
|
|
||||||
|
# Check current user
|
||||||
|
curl http://localhost:8000/api/auth/me -b cookies.txt
|
||||||
|
|
||||||
|
# Check auth status (lightweight)
|
||||||
|
curl http://localhost:8000/api/auth/check -b cookies.txt
|
||||||
|
|
||||||
|
# Logout
|
||||||
|
curl -X POST http://localhost:8000/api/auth/logout -b cookies.txt
|
||||||
|
|
||||||
|
# Test protected route
|
||||||
|
curl http://localhost:8000/api/protected -b cookies.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Backend: Optional Auth
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flask import session
|
||||||
|
|
||||||
|
@my_bp.route("/public-with-extras")
|
||||||
|
def public_route():
|
||||||
|
user_id = session.get('user_id')
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
# Show extra content for authenticated users
|
||||||
|
return {"data": "...", "extras": "..."}
|
||||||
|
else:
|
||||||
|
# Public content only
|
||||||
|
return {"data": "..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend: Redirect After Login
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { login } = useAuth();
|
||||||
|
|
||||||
|
const handleLogin = async (username: string, password: string) => {
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
window.location.href = '/dashboard';
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Login failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend: Protected Route Component
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useAuth } from './useAuth';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isAuthenticated, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) return <div>Loading...</div>;
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage in routes:
|
||||||
|
<Route path="/admin" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AdminPanel />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Authentication required" on /api/auth/me
|
||||||
|
|
||||||
|
✅ **Normal** - User is not logged in. This is expected behavior.
|
||||||
|
|
||||||
|
### Session not persisting across requests
|
||||||
|
|
||||||
|
- Check `credentials: 'include'` in fetch calls
|
||||||
|
- Verify `FLASK_SECRET_KEY` is set
|
||||||
|
- Check browser cookies are enabled
|
||||||
|
|
||||||
|
### 403 Forbidden on decorated route
|
||||||
|
|
||||||
|
- Verify user is logged in
|
||||||
|
- Check user role matches required role
|
||||||
|
- Inspect response for `required_roles` and `your_role`
|
||||||
|
|
||||||
|
## Files Reference
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `server/routes/auth.py` | Auth endpoints (login, logout, /me) |
|
||||||
|
| `server/permissions.py` | Permission decorators |
|
||||||
|
| `dashboard/src/apiAuth.ts` | Frontend API client |
|
||||||
|
| `dashboard/src/useAuth.tsx` | React context/hooks |
|
||||||
|
| `models/models.py` | User model and UserRole enum |
|
||||||
|
|
||||||
|
## Full Documentation
|
||||||
|
|
||||||
|
See `AUTH_SYSTEM.md` for complete documentation including:
|
||||||
|
- Architecture details
|
||||||
|
- Security considerations
|
||||||
|
- API reference
|
||||||
|
- Testing guide
|
||||||
|
- Production checklist
|
||||||
522
AUTH_SYSTEM.md
Normal file
522
AUTH_SYSTEM.md
Normal file
@@ -0,0 +1,522 @@
|
|||||||
|
# Authentication System Documentation
|
||||||
|
|
||||||
|
This document describes the authentication and authorization system implemented in the infoscreen_2025 project.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The system provides session-based authentication with role-based access control (RBAC). It includes:
|
||||||
|
|
||||||
|
- **Backend**: Flask session-based auth with bcrypt password hashing
|
||||||
|
- **Frontend**: React context/hooks for managing authentication state
|
||||||
|
- **Permissions**: Decorators for protecting routes based on user roles
|
||||||
|
- **Roles**: Four levels (user, editor, admin, superadmin)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Backend Components
|
||||||
|
|
||||||
|
#### 1. Auth Routes (`server/routes/auth.py`)
|
||||||
|
|
||||||
|
Provides authentication endpoints:
|
||||||
|
|
||||||
|
- **`POST /api/auth/login`** - Authenticate user and create session
|
||||||
|
- **`POST /api/auth/logout`** - End user session
|
||||||
|
- **`GET /api/auth/me`** - Get current user info (protected)
|
||||||
|
- **`GET /api/auth/check`** - Quick auth status check
|
||||||
|
|
||||||
|
#### 2. Permission Decorators (`server/permissions.py`)
|
||||||
|
|
||||||
|
Decorators for protecting routes:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from server.permissions import require_role, admin_or_higher, editor_or_higher
|
||||||
|
|
||||||
|
# Require specific role(s)
|
||||||
|
@app.route('/admin-settings')
|
||||||
|
@require_role('admin', 'superadmin')
|
||||||
|
def admin_settings():
|
||||||
|
return "Admin only"
|
||||||
|
|
||||||
|
# Convenience decorators
|
||||||
|
@app.route('/settings')
|
||||||
|
@admin_or_higher # admin or superadmin
|
||||||
|
def settings():
|
||||||
|
return "Settings"
|
||||||
|
|
||||||
|
@app.route('/events', methods=['POST'])
|
||||||
|
@editor_or_higher # editor, admin, or superadmin
|
||||||
|
def create_event():
|
||||||
|
return "Create event"
|
||||||
|
```
|
||||||
|
|
||||||
|
Available decorators:
|
||||||
|
- `@require_auth` - Just require authentication
|
||||||
|
- `@require_role(*roles)` - Require any of specified roles
|
||||||
|
- `@superadmin_only` - Superadmin only
|
||||||
|
- `@admin_or_higher` - Admin or superadmin
|
||||||
|
- `@editor_or_higher` - Editor, admin, or superadmin
|
||||||
|
|
||||||
|
#### 3. Session Configuration (`server/wsgi.py`)
|
||||||
|
|
||||||
|
Flask session configured with:
|
||||||
|
- Secret key from `FLASK_SECRET_KEY` environment variable
|
||||||
|
- HTTPOnly cookies (prevent XSS)
|
||||||
|
- SameSite=Lax (CSRF protection)
|
||||||
|
- Secure flag in production (HTTPS only)
|
||||||
|
|
||||||
|
### Frontend Components
|
||||||
|
|
||||||
|
#### 1. API Client (`dashboard/src/apiAuth.ts`)
|
||||||
|
|
||||||
|
TypeScript functions for auth operations:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { login, logout, fetchCurrentUser } from './apiAuth';
|
||||||
|
|
||||||
|
// Login
|
||||||
|
await login('username', 'password');
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
const user = await fetchCurrentUser();
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
await logout();
|
||||||
|
|
||||||
|
// Check auth status (lightweight)
|
||||||
|
const { authenticated, role } = await checkAuth();
|
||||||
|
```
|
||||||
|
|
||||||
|
Helper functions:
|
||||||
|
```typescript
|
||||||
|
import { hasRole, hasAnyRole, isAdminOrHigher } from './apiAuth';
|
||||||
|
|
||||||
|
if (isAdminOrHigher(user)) {
|
||||||
|
// Show admin UI
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Auth Context/Hooks (`dashboard/src/useAuth.tsx`)
|
||||||
|
|
||||||
|
React context for managing auth state:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useAuth, useCurrentUser, useIsAuthenticated } from './useAuth';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
// Full auth context
|
||||||
|
const { user, login, logout, loading, error, isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
// Or just what you need
|
||||||
|
const user = useCurrentUser();
|
||||||
|
const isAuth = useIsAuthenticated();
|
||||||
|
|
||||||
|
if (loading) return <div>Loading...</div>;
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <LoginForm onLogin={login} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div>Welcome {user.username}!</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Roles
|
||||||
|
|
||||||
|
Four hierarchical roles with increasing permissions:
|
||||||
|
|
||||||
|
| Role | Value | Description | Use Case |
|
||||||
|
|------|-------|-------------|----------|
|
||||||
|
| **User** | `user` | Read-only access | View events only |
|
||||||
|
| **Editor** | `editor` | Can CRUD events/media | Content managers |
|
||||||
|
| **Admin** | `admin` | Manage settings, users (except superadmin), groups | Organization staff |
|
||||||
|
| **Superadmin** | `superadmin` | Full system access | Developers, system admins |
|
||||||
|
|
||||||
|
### Permission Matrix
|
||||||
|
|
||||||
|
| Action | User | Editor | Admin | Superadmin |
|
||||||
|
|--------|------|--------|-------|------------|
|
||||||
|
| View events | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Create/edit events | ❌ | ✅ | ✅ | ✅ |
|
||||||
|
| Manage media | ❌ | ✅ | ✅ | ✅ |
|
||||||
|
| Manage groups/clients | ❌ | ❌ | ✅ | ✅ |
|
||||||
|
| Manage users (non-superadmin) | ❌ | ❌ | ✅ | ✅ |
|
||||||
|
| Manage settings | ❌ | ❌ | ✅ | ✅ |
|
||||||
|
| Manage superadmins | ❌ | ❌ | ❌ | ✅ |
|
||||||
|
| System configuration | ❌ | ❌ | ❌ | ✅ |
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Environment Configuration
|
||||||
|
|
||||||
|
Add to your `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Flask session secret key (REQUIRED)
|
||||||
|
# Generate with: python -c 'import secrets; print(secrets.token_hex(32))'
|
||||||
|
FLASK_SECRET_KEY=your_secret_key_here
|
||||||
|
|
||||||
|
# Superadmin account (REQUIRED for initial setup)
|
||||||
|
DEFAULT_SUPERADMIN_USERNAME=superadmin
|
||||||
|
DEFAULT_SUPERADMIN_PASSWORD=your_secure_password
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Database Initialization
|
||||||
|
|
||||||
|
The superadmin user is created automatically when containers start. See `SUPERADMIN_SETUP.md` for details.
|
||||||
|
|
||||||
|
### 3. Frontend Integration
|
||||||
|
|
||||||
|
Wrap your app with `AuthProvider` in `main.tsx` or `App.tsx`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { AuthProvider } from './useAuth';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
{/* Your app components */}
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Backend: Protecting Routes
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flask import Blueprint
|
||||||
|
from server.permissions import require_role, admin_or_higher
|
||||||
|
|
||||||
|
users_bp = Blueprint("users", __name__, url_prefix="/api/users")
|
||||||
|
|
||||||
|
@users_bp.route("", methods=["GET"])
|
||||||
|
@admin_or_higher
|
||||||
|
def list_users():
|
||||||
|
"""List all users - admin+ only"""
|
||||||
|
# Implementation
|
||||||
|
pass
|
||||||
|
|
||||||
|
@users_bp.route("", methods=["POST"])
|
||||||
|
@require_role('superadmin')
|
||||||
|
def create_superadmin():
|
||||||
|
"""Create superadmin - superadmin only"""
|
||||||
|
# Implementation
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend: Conditional Rendering
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useAuth } from './useAuth';
|
||||||
|
import { isAdminOrHigher, isEditorOrHigher } from './apiAuth';
|
||||||
|
|
||||||
|
function NavigationMenu() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav>
|
||||||
|
<a href="/dashboard">Dashboard</a>
|
||||||
|
<a href="/events">Events</a>
|
||||||
|
|
||||||
|
{isEditorOrHigher(user) && (
|
||||||
|
<a href="/events/new">Create Event</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAdminOrHigher(user) && (
|
||||||
|
<>
|
||||||
|
<a href="/settings">Settings</a>
|
||||||
|
<a href="/users">Manage Users</a>
|
||||||
|
<a href="/groups">Manage Groups</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend: Login Form Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useAuth } from './useAuth';
|
||||||
|
|
||||||
|
function LoginPage() {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const { login, loading, error } = useAuth();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
// Redirect on success
|
||||||
|
window.location.href = '/dashboard';
|
||||||
|
} catch (err) {
|
||||||
|
// Error is already in auth context
|
||||||
|
console.error('Login failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<h1>Login</h1>
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading}>
|
||||||
|
{loading ? 'Logging in...' : 'Login'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Backend Security
|
||||||
|
|
||||||
|
1. **Password Hashing**: All passwords hashed with bcrypt (salt rounds default)
|
||||||
|
2. **Session Security**:
|
||||||
|
- HTTPOnly cookies (prevent XSS access)
|
||||||
|
- SameSite=Lax (CSRF protection)
|
||||||
|
- Secure flag in production (HTTPS only)
|
||||||
|
3. **Secret Key**: Must be set via environment variable, not hardcoded
|
||||||
|
4. **Role Checking**: Server-side validation on every protected route
|
||||||
|
|
||||||
|
### Frontend Security
|
||||||
|
|
||||||
|
1. **Credentials**: Always use `credentials: 'include'` in fetch calls
|
||||||
|
2. **No Password Storage**: Never store passwords in localStorage/sessionStorage
|
||||||
|
3. **Role Gating**: UI gating is convenience, not security (always validate server-side)
|
||||||
|
4. **HTTPS**: Always use HTTPS in production
|
||||||
|
|
||||||
|
### Production Checklist
|
||||||
|
|
||||||
|
- [ ] Generate strong `FLASK_SECRET_KEY` (32+ bytes)
|
||||||
|
- [ ] Set `SESSION_COOKIE_SECURE=True` (handled automatically by ENV=production)
|
||||||
|
- [ ] Use HTTPS with valid TLS certificate
|
||||||
|
- [ ] Change default superadmin password after first login
|
||||||
|
- [ ] Review and audit user roles regularly
|
||||||
|
- [ ] Enable audit logging (future enhancement)
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Authentication Endpoints
|
||||||
|
|
||||||
|
#### POST /api/auth/login
|
||||||
|
|
||||||
|
Authenticate user and create session.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "string",
|
||||||
|
"password": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Login successful",
|
||||||
|
"user": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "admin",
|
||||||
|
"role": "admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
- `400` - Missing username or password
|
||||||
|
- `401` - Invalid credentials or account disabled
|
||||||
|
|
||||||
|
#### POST /api/auth/logout
|
||||||
|
|
||||||
|
End current session.
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Logout successful"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /api/auth/me
|
||||||
|
|
||||||
|
Get current user information (requires authentication).
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"username": "admin",
|
||||||
|
"role": "admin",
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
- `401` - Not authenticated or account disabled
|
||||||
|
|
||||||
|
#### GET /api/auth/check
|
||||||
|
|
||||||
|
Quick authentication status check.
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"authenticated": true,
|
||||||
|
"role": "admin"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or if not authenticated:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"authenticated": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Manual Testing
|
||||||
|
|
||||||
|
1. **Create test users** (via database or future user management UI):
|
||||||
|
```sql
|
||||||
|
INSERT INTO users (username, password_hash, role, is_active)
|
||||||
|
VALUES ('testuser', '<bcrypt_hash>', 'user', 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Test login**:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"superadmin","password":"your_password"}' \
|
||||||
|
-c cookies.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Test /me endpoint**:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/api/auth/me -b cookies.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Test protected route**:
|
||||||
|
```bash
|
||||||
|
# Should fail without auth
|
||||||
|
curl http://localhost:8000/api/protected
|
||||||
|
|
||||||
|
# Should work with cookie
|
||||||
|
curl http://localhost:8000/api/protected -b cookies.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automated Testing
|
||||||
|
|
||||||
|
Example test cases (to be implemented):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_login_success():
|
||||||
|
response = client.post('/api/auth/login', json={
|
||||||
|
'username': 'testuser',
|
||||||
|
'password': 'testpass'
|
||||||
|
})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'user' in response.json
|
||||||
|
|
||||||
|
def test_login_invalid_credentials():
|
||||||
|
response = client.post('/api/auth/login', json={
|
||||||
|
'username': 'testuser',
|
||||||
|
'password': 'wrongpass'
|
||||||
|
})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_me_authenticated():
|
||||||
|
# Login first
|
||||||
|
client.post('/api/auth/login', json={'username': 'testuser', 'password': 'testpass'})
|
||||||
|
response = client.get('/api/auth/me')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json['username'] == 'testuser'
|
||||||
|
|
||||||
|
def test_me_not_authenticated():
|
||||||
|
response = client.get('/api/auth/me')
|
||||||
|
assert response.status_code == 401
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Login Not Working
|
||||||
|
|
||||||
|
**Symptoms**: Login endpoint returns 401 even with correct credentials
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Verify user exists in database: `SELECT * FROM users WHERE username='...'`
|
||||||
|
2. Check password hash is valid bcrypt format
|
||||||
|
3. Verify user `is_active=1`
|
||||||
|
4. Check server logs for bcrypt errors
|
||||||
|
|
||||||
|
### Session Not Persisting
|
||||||
|
|
||||||
|
**Symptoms**: `/api/auth/me` returns 401 after successful login
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Verify `FLASK_SECRET_KEY` is set
|
||||||
|
2. Check frontend is sending `credentials: 'include'` in fetch
|
||||||
|
3. Verify cookies are being set (check browser DevTools)
|
||||||
|
4. Check CORS settings if frontend/backend on different domains
|
||||||
|
|
||||||
|
### Permission Denied on Protected Route
|
||||||
|
|
||||||
|
**Symptoms**: 403 error on decorated routes
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Verify user is logged in (`/api/auth/me`)
|
||||||
|
2. Check user role matches required role
|
||||||
|
3. Verify decorator is applied correctly
|
||||||
|
4. Check session hasn't expired
|
||||||
|
|
||||||
|
### TypeScript Errors in Frontend
|
||||||
|
|
||||||
|
**Symptoms**: Type errors when using auth hooks
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Ensure `AuthProvider` is wrapping your app
|
||||||
|
2. Import types correctly: `import type { User } from './apiAuth'`
|
||||||
|
3. Check TypeScript config for `verbatimModuleSyntax`
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
See `userrole-management.md` for the complete implementation roadmap:
|
||||||
|
|
||||||
|
1. ✅ **Extend User Model** - Done
|
||||||
|
2. ✅ **Seed Superadmin** - Done (`init_defaults.py`)
|
||||||
|
3. ✅ **Expose Current User Role** - Done (this document)
|
||||||
|
4. ⏳ **Implement Minimal Role Enforcement** - Apply decorators to existing routes
|
||||||
|
5. ⏳ **Test the Flow** - Verify permissions work correctly
|
||||||
|
6. ⏳ **Frontend Role Gating** - Update UI components
|
||||||
|
7. ⏳ **User Management UI** - Build admin interface
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- User model: `models/models.py`
|
||||||
|
- Auth routes: `server/routes/auth.py`
|
||||||
|
- Permissions: `server/permissions.py`
|
||||||
|
- API client: `dashboard/src/apiAuth.ts`
|
||||||
|
- Auth context: `dashboard/src/useAuth.tsx`
|
||||||
|
- Flask sessions: https://flask.palletsprojects.com/en/latest/api/#sessions
|
||||||
|
- Bcrypt: https://pypi.org/project/bcrypt/
|
||||||
979
CLIENT_MONITORING_SPECIFICATION.md
Normal file
979
CLIENT_MONITORING_SPECIFICATION.md
Normal file
@@ -0,0 +1,979 @@
|
|||||||
|
# Client-Side Monitoring Specification
|
||||||
|
|
||||||
|
**Version:** 1.0
|
||||||
|
**Date:** 2026-03-10
|
||||||
|
**For:** Infoscreen Client Implementation
|
||||||
|
**Server Endpoint:** `192.168.43.201:8000` (or your production server)
|
||||||
|
**MQTT Broker:** `192.168.43.201:1883` (or your production MQTT broker)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
Each infoscreen client must implement health monitoring and logging capabilities to report status to the central server via MQTT.
|
||||||
|
|
||||||
|
### 1.1 Goals
|
||||||
|
- **Detect failures:** Process crashes, frozen screens, content mismatches
|
||||||
|
- **Provide visibility:** Real-time health status visible on server dashboard
|
||||||
|
- **Enable remote diagnosis:** Centralized log storage for debugging
|
||||||
|
- **Auto-recovery:** Attempt automatic restart on failure
|
||||||
|
|
||||||
|
### 1.2 Architecture
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Infoscreen Client │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Media Player │ │ Watchdog │ │
|
||||||
|
│ │ (VLC/Chrome) │◄───│ Monitor │ │
|
||||||
|
│ └──────────────┘ └──────┬───────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────────┐ │ │
|
||||||
|
│ │ Event Mgr │ │ │
|
||||||
|
│ │ (receives │ │ │
|
||||||
|
│ │ schedule) │◄───────────┘ │
|
||||||
|
│ └──────┬───────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────▼───────────────────────┐ │
|
||||||
|
│ │ MQTT Client │ │
|
||||||
|
│ │ - Heartbeat (every 60s) │ │
|
||||||
|
│ │ - Logs (error/warn/info) │ │
|
||||||
|
│ │ - Health metrics (every 5s) │ │
|
||||||
|
│ └──────┬────────────────────────┘ │
|
||||||
|
└─────────┼──────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ MQTT over TCP
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ MQTT Broker │
|
||||||
|
│ (server) │
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Current Compatibility Notes
|
||||||
|
- The server now accepts both the original specification payloads and the currently implemented Phase 3 client payloads.
|
||||||
|
- `infoscreen/{uuid}/health` may currently contain a reduced payload with only `expected_state.event_id` and `actual_state.process|pid|status`. Additional `health_metrics` fields from this specification remain recommended.
|
||||||
|
- `event_id` is still specified as an integer. For compatibility with the current Phase 3 client, the server also tolerates string values such as `event_123` and extracts the numeric suffix where possible.
|
||||||
|
- If the client sends `process_health` inside `infoscreen/{uuid}/dashboard`, the server treats it as a fallback source for `current_process`, `process_pid`, `process_status`, and `current_event_id`.
|
||||||
|
- Long term, the preferred client payload remains the structure in this specification so the server can surface richer monitoring data such as screen state and resource metrics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. MQTT Protocol Specification
|
||||||
|
|
||||||
|
### 2.1 Connection Parameters
|
||||||
|
```
|
||||||
|
Broker: 192.168.43.201 (or DNS hostname)
|
||||||
|
Port: 1883 (standard MQTT)
|
||||||
|
Protocol: MQTT v3.1.1
|
||||||
|
Client ID: "infoscreen-{client_uuid}"
|
||||||
|
Clean Session: false (retain subscriptions)
|
||||||
|
Keep Alive: 60 seconds
|
||||||
|
Username/Password: (if configured on broker)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 QoS Levels
|
||||||
|
- **Heartbeat:** QoS 0 (fire and forget, high frequency)
|
||||||
|
- **Logs (ERROR/WARN):** QoS 1 (at least once delivery, important)
|
||||||
|
- **Logs (INFO):** QoS 0 (optional, high volume)
|
||||||
|
- **Health metrics:** QoS 0 (frequent, latest value matters)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Topic Structure & Payload Formats
|
||||||
|
|
||||||
|
### 3.1 Log Messages
|
||||||
|
|
||||||
|
#### Topic Pattern:
|
||||||
|
```
|
||||||
|
infoscreen/{client_uuid}/logs/{level}
|
||||||
|
```
|
||||||
|
|
||||||
|
Where `{level}` is one of: `error`, `warn`, `info`
|
||||||
|
|
||||||
|
#### Payload Format (JSON):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2026-03-10T07:30:00Z",
|
||||||
|
"message": "Human-readable error description",
|
||||||
|
"context": {
|
||||||
|
"event_id": 42,
|
||||||
|
"process": "vlc",
|
||||||
|
"error_code": "NETWORK_TIMEOUT",
|
||||||
|
"additional_key": "any relevant data"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Field Specifications:
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `timestamp` | string (ISO 8601 UTC) | Yes | When the event occurred. Use `YYYY-MM-DDTHH:MM:SSZ` format |
|
||||||
|
| `message` | string | Yes | Human-readable description of the event (max 1000 chars) |
|
||||||
|
| `context` | object | No | Additional structured data (will be stored as JSON) |
|
||||||
|
|
||||||
|
#### Example Topics:
|
||||||
|
```
|
||||||
|
infoscreen/9b8d1856-ff34-4864-a726-12de072d0f77/logs/error
|
||||||
|
infoscreen/9b8d1856-ff34-4864-a726-12de072d0f77/logs/warn
|
||||||
|
infoscreen/9b8d1856-ff34-4864-a726-12de072d0f77/logs/info
|
||||||
|
```
|
||||||
|
|
||||||
|
#### When to Send Logs:
|
||||||
|
|
||||||
|
**ERROR (Always send):**
|
||||||
|
- Process crashed (VLC/Chromium/PDF viewer terminated unexpectedly)
|
||||||
|
- Content failed to load (404, network timeout, corrupt file)
|
||||||
|
- Hardware failure detected (display off, audio device missing)
|
||||||
|
- Exception caught in main event loop
|
||||||
|
- Maximum restart attempts exceeded
|
||||||
|
|
||||||
|
**WARN (Always send):**
|
||||||
|
- Process restarted automatically (after crash)
|
||||||
|
- High resource usage (CPU >80%, RAM >90%)
|
||||||
|
- Slow performance (frame drops, lag)
|
||||||
|
- Non-critical failures (screenshot capture failed, cache full)
|
||||||
|
- Fallback content displayed (primary source unavailable)
|
||||||
|
|
||||||
|
**INFO (Send in development, optional in production):**
|
||||||
|
- Process started successfully
|
||||||
|
- Event transition (switched from video to presentation)
|
||||||
|
- Content loaded successfully
|
||||||
|
- Watchdog service started/stopped
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Health Metrics
|
||||||
|
|
||||||
|
#### Topic Pattern:
|
||||||
|
```
|
||||||
|
infoscreen/{client_uuid}/health
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Payload Format (JSON):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2026-03-10T07:30:00Z",
|
||||||
|
"expected_state": {
|
||||||
|
"event_id": 42,
|
||||||
|
"event_type": "video",
|
||||||
|
"media_file": "presentation.mp4",
|
||||||
|
"started_at": "2026-03-10T07:15:00Z"
|
||||||
|
},
|
||||||
|
"actual_state": {
|
||||||
|
"process": "vlc",
|
||||||
|
"pid": 1234,
|
||||||
|
"status": "running",
|
||||||
|
"uptime_seconds": 900,
|
||||||
|
"position": 45.3,
|
||||||
|
"duration": 180.0
|
||||||
|
},
|
||||||
|
"health_metrics": {
|
||||||
|
"screen_on": true,
|
||||||
|
"last_frame_update": "2026-03-10T07:29:58Z",
|
||||||
|
"frames_dropped": 2,
|
||||||
|
"network_errors": 0,
|
||||||
|
"cpu_percent": 15.3,
|
||||||
|
"memory_mb": 234
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Field Specifications:
|
||||||
|
|
||||||
|
**expected_state:**
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `event_id` | integer | Yes | Current event ID from scheduler |
|
||||||
|
| `event_type` | string | Yes | `presentation`, `video`, `website`, `webuntis`, `message` |
|
||||||
|
| `media_file` | string | No | Filename or URL of current content |
|
||||||
|
| `started_at` | string (ISO 8601) | Yes | When this event started playing |
|
||||||
|
|
||||||
|
**actual_state:**
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `process` | string | Yes | `vlc`, `chromium`, `pdf_viewer`, `none` |
|
||||||
|
| `pid` | integer | No | Process ID (if running) |
|
||||||
|
| `status` | string | Yes | `running`, `crashed`, `starting`, `stopped` |
|
||||||
|
| `uptime_seconds` | integer | No | How long process has been running |
|
||||||
|
| `position` | float | No | Current playback position (seconds, for video/audio) |
|
||||||
|
| `duration` | float | No | Total content duration (seconds) |
|
||||||
|
|
||||||
|
**health_metrics:**
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `screen_on` | boolean | Yes | Is display powered on? |
|
||||||
|
| `last_frame_update` | string (ISO 8601) | No | Last time screen content changed |
|
||||||
|
| `frames_dropped` | integer | No | Video frames dropped (performance indicator) |
|
||||||
|
| `network_errors` | integer | No | Count of network errors in last interval |
|
||||||
|
| `cpu_percent` | float | No | CPU usage (0-100) |
|
||||||
|
| `memory_mb` | integer | No | RAM usage in megabytes |
|
||||||
|
|
||||||
|
#### Sending Frequency:
|
||||||
|
- **Normal operation:** Every 5 seconds
|
||||||
|
- **During startup/transition:** Every 1 second
|
||||||
|
- **After error:** Immediately + every 2 seconds until recovered
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 Enhanced Heartbeat
|
||||||
|
|
||||||
|
The existing heartbeat topic should be enhanced to include process status.
|
||||||
|
|
||||||
|
#### Topic Pattern:
|
||||||
|
```
|
||||||
|
infoscreen/{client_uuid}/heartbeat
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Enhanced Payload Format (JSON):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uuid": "9b8d1856-ff34-4864-a726-12de072d0f77",
|
||||||
|
"timestamp": "2026-03-10T07:30:00Z",
|
||||||
|
"current_process": "vlc",
|
||||||
|
"process_pid": 1234,
|
||||||
|
"process_status": "running",
|
||||||
|
"current_event_id": 42
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### New Fields (add to existing heartbeat):
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `current_process` | string | No | Name of active media player process |
|
||||||
|
| `process_pid` | integer | No | Process ID |
|
||||||
|
| `process_status` | string | No | `running`, `crashed`, `starting`, `stopped` |
|
||||||
|
| `current_event_id` | integer | No | Event ID currently being displayed |
|
||||||
|
|
||||||
|
#### Sending Frequency:
|
||||||
|
- Keep existing: **Every 60 seconds**
|
||||||
|
- Include new fields if available
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Process Monitoring Requirements
|
||||||
|
|
||||||
|
### 4.1 Processes to Monitor
|
||||||
|
|
||||||
|
| Media Type | Process Name | How to Detect |
|
||||||
|
|------------|--------------|---------------|
|
||||||
|
| Video | `vlc` | `ps aux \| grep vlc` or `pgrep vlc` |
|
||||||
|
| Website/WebUntis | `chromium` or `chromium-browser` | `pgrep chromium` |
|
||||||
|
| PDF Presentation | `evince`, `okular`, or custom viewer | `pgrep {viewer_name}` |
|
||||||
|
|
||||||
|
### 4.2 Monitoring Checks (Every 5 seconds)
|
||||||
|
|
||||||
|
#### Check 1: Process Alive
|
||||||
|
```
|
||||||
|
Goal: Verify expected process is running
|
||||||
|
Method:
|
||||||
|
- Get list of running processes (psutil or `ps`)
|
||||||
|
- Check if expected process name exists
|
||||||
|
- Match PID if known
|
||||||
|
Result:
|
||||||
|
- If missing → status = "crashed"
|
||||||
|
- If found → status = "running"
|
||||||
|
Action on crash:
|
||||||
|
- Send ERROR log immediately
|
||||||
|
- Attempt restart (max 3 attempts)
|
||||||
|
- Send WARN log on each restart
|
||||||
|
- If max restarts exceeded → send ERROR log, display fallback
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Check 2: Process Responsive
|
||||||
|
```
|
||||||
|
Goal: Detect frozen processes
|
||||||
|
Method:
|
||||||
|
- For VLC: Query HTTP interface (status.json)
|
||||||
|
- For Chromium: Use DevTools Protocol (CDP)
|
||||||
|
- For custom viewers: Check last screen update time
|
||||||
|
Result:
|
||||||
|
- If same frame >30 seconds → likely frozen
|
||||||
|
- If playback position not advancing → frozen
|
||||||
|
Action on freeze:
|
||||||
|
- Send WARN log
|
||||||
|
- Force refresh (reload page, seek video, next slide)
|
||||||
|
- If refresh fails → restart process
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Check 3: Content Match
|
||||||
|
```
|
||||||
|
Goal: Verify correct content is displayed
|
||||||
|
Method:
|
||||||
|
- Compare expected event_id with actual media/URL
|
||||||
|
- Check scheduled time window (is event still active?)
|
||||||
|
Result:
|
||||||
|
- Mismatch → content error
|
||||||
|
Action:
|
||||||
|
- Send WARN log
|
||||||
|
- Reload correct event from scheduler
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Process Control Interface Requirements
|
||||||
|
|
||||||
|
### 5.1 VLC Control
|
||||||
|
|
||||||
|
**Requirement:** Enable VLC HTTP interface for monitoring
|
||||||
|
|
||||||
|
**Launch Command:**
|
||||||
|
```bash
|
||||||
|
vlc --intf http --http-host 127.0.0.1 --http-port 8080 --http-password "vlc_password" \
|
||||||
|
--fullscreen --loop /path/to/video.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Query:**
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8080/requests/status.json --user ":vlc_password"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response Fields to Monitor:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"state": "playing", // "playing", "paused", "stopped"
|
||||||
|
"position": 0.25, // 0.0-1.0 (25% through)
|
||||||
|
"time": 45, // seconds into playback
|
||||||
|
"length": 180, // total duration in seconds
|
||||||
|
"volume": 256 // 0-512
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 Chromium Control
|
||||||
|
|
||||||
|
**Requirement:** Enable Chrome DevTools Protocol (CDP)
|
||||||
|
|
||||||
|
**Launch Command:**
|
||||||
|
```bash
|
||||||
|
chromium --remote-debugging-port=9222 --kiosk --app=https://example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Query:**
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:9222/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response Fields to Monitor:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"url": "https://example.com",
|
||||||
|
"title": "Page Title",
|
||||||
|
"type": "page"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Advanced:** Use CDP WebSocket for events (page load, navigation, errors)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 PDF Viewer (Custom or Standard)
|
||||||
|
|
||||||
|
**Option A: Standard Viewer (e.g., Evince)**
|
||||||
|
- No built-in API
|
||||||
|
- Monitor via process check + screenshot comparison
|
||||||
|
|
||||||
|
**Option B: Custom Python Viewer**
|
||||||
|
- Implement REST API for status queries
|
||||||
|
- Track: current page, total pages, last transition time
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Watchdog Service Architecture
|
||||||
|
|
||||||
|
### 6.1 Service Components
|
||||||
|
|
||||||
|
**Component 1: Process Monitor Thread**
|
||||||
|
```
|
||||||
|
Responsibilities:
|
||||||
|
- Check process alive every 5 seconds
|
||||||
|
- Detect crashes and frozen processes
|
||||||
|
- Attempt automatic restart
|
||||||
|
- Send health metrics via MQTT
|
||||||
|
|
||||||
|
State Machine:
|
||||||
|
IDLE → STARTING → RUNNING → (if crash) → RESTARTING → RUNNING
|
||||||
|
→ (if max restarts) → FAILED
|
||||||
|
```
|
||||||
|
|
||||||
|
**Component 2: MQTT Publisher Thread**
|
||||||
|
```
|
||||||
|
Responsibilities:
|
||||||
|
- Maintain MQTT connection
|
||||||
|
- Send heartbeat every 60 seconds
|
||||||
|
- Send logs on-demand (queued from other components)
|
||||||
|
- Send health metrics every 5 seconds
|
||||||
|
- Reconnect on connection loss
|
||||||
|
```
|
||||||
|
|
||||||
|
**Component 3: Event Manager Integration**
|
||||||
|
```
|
||||||
|
Responsibilities:
|
||||||
|
- Receive event schedule from server
|
||||||
|
- Notify watchdog of expected process/content
|
||||||
|
- Launch media player processes
|
||||||
|
- Handle event transitions
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Service Lifecycle
|
||||||
|
|
||||||
|
**On Startup:**
|
||||||
|
1. Load configuration (client UUID, MQTT broker, etc.)
|
||||||
|
2. Connect to MQTT broker
|
||||||
|
3. Send INFO log: "Watchdog service started"
|
||||||
|
4. Wait for first event from scheduler
|
||||||
|
|
||||||
|
**During Operation:**
|
||||||
|
1. Monitor loop runs every 5 seconds
|
||||||
|
2. Check expected vs actual process state
|
||||||
|
3. Send health metrics
|
||||||
|
4. Handle failures (log + restart)
|
||||||
|
|
||||||
|
**On Shutdown:**
|
||||||
|
1. Send INFO log: "Watchdog service stopping"
|
||||||
|
2. Gracefully stop monitored processes
|
||||||
|
3. Disconnect from MQTT
|
||||||
|
4. Exit cleanly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Auto-Recovery Logic
|
||||||
|
|
||||||
|
### 7.1 Restart Strategy
|
||||||
|
|
||||||
|
**Step 1: Detect Failure**
|
||||||
|
```
|
||||||
|
Trigger: Process not found in process list
|
||||||
|
Action:
|
||||||
|
- Log ERROR: "Process {name} crashed"
|
||||||
|
- Increment restart counter
|
||||||
|
- Check if within retry limit (max 3)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Attempt Restart**
|
||||||
|
```
|
||||||
|
If restart_attempts < MAX_RESTARTS:
|
||||||
|
- Log WARN: "Attempting restart ({attempt}/{MAX_RESTARTS})"
|
||||||
|
- Kill any zombie processes
|
||||||
|
- Wait 2 seconds (cooldown)
|
||||||
|
- Launch process with same parameters
|
||||||
|
- Wait 5 seconds for startup
|
||||||
|
- Verify process is running
|
||||||
|
- If success: reset restart counter, log INFO
|
||||||
|
- If fail: increment counter, repeat
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Permanent Failure**
|
||||||
|
```
|
||||||
|
If restart_attempts >= MAX_RESTARTS:
|
||||||
|
- Log ERROR: "Max restart attempts exceeded, failing over"
|
||||||
|
- Display fallback content (static image with error message)
|
||||||
|
- Send notification to server (separate alert topic, optional)
|
||||||
|
- Wait for manual intervention or scheduler event change
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Restart Cooldown
|
||||||
|
|
||||||
|
**Purpose:** Prevent rapid restart loops that waste resources
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```
|
||||||
|
After each restart attempt:
|
||||||
|
- Wait 2 seconds before next restart
|
||||||
|
- After 3 failures: wait 30 seconds before trying again
|
||||||
|
- Reset counter on successful run >5 minutes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Resource Monitoring
|
||||||
|
|
||||||
|
### 8.1 System Metrics to Track
|
||||||
|
|
||||||
|
**CPU Usage:**
|
||||||
|
```
|
||||||
|
Method: Read /proc/stat or use psutil.cpu_percent()
|
||||||
|
Frequency: Every 5 seconds
|
||||||
|
Threshold: Warn if >80% for >60 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
**Memory Usage:**
|
||||||
|
```
|
||||||
|
Method: Read /proc/meminfo or use psutil.virtual_memory()
|
||||||
|
Frequency: Every 5 seconds
|
||||||
|
Threshold: Warn if >90% for >30 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
**Display Status:**
|
||||||
|
```
|
||||||
|
Method: Check DPMS state or xset query
|
||||||
|
Frequency: Every 30 seconds
|
||||||
|
Threshold: Error if display off (unexpected)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Network Connectivity:**
|
||||||
|
```
|
||||||
|
Method: Ping server or check MQTT connection
|
||||||
|
Frequency: Every 60 seconds
|
||||||
|
Threshold: Warn if no server connectivity
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Development vs Production Mode
|
||||||
|
|
||||||
|
### 9.1 Development Mode
|
||||||
|
|
||||||
|
**Enable via:** Environment variable `DEBUG=true` or `ENV=development`
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Send INFO level logs
|
||||||
|
- More verbose logging to console
|
||||||
|
- Shorter monitoring intervals (faster feedback)
|
||||||
|
- Screenshot capture every 30 seconds
|
||||||
|
- No rate limiting on logs
|
||||||
|
|
||||||
|
### 9.2 Production Mode
|
||||||
|
|
||||||
|
**Enable via:** `ENV=production`
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Send only ERROR and WARN logs
|
||||||
|
- Minimal console output
|
||||||
|
- Standard monitoring intervals
|
||||||
|
- Screenshot capture every 60 seconds
|
||||||
|
- Rate limiting: max 10 logs per minute per level
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Configuration File Format
|
||||||
|
|
||||||
|
### 10.1 Recommended Config: JSON
|
||||||
|
|
||||||
|
**File:** `/etc/infoscreen/config.json` or `~/.config/infoscreen/config.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client": {
|
||||||
|
"uuid": "9b8d1856-ff34-4864-a726-12de072d0f77",
|
||||||
|
"hostname": "infoscreen-room-101"
|
||||||
|
},
|
||||||
|
"mqtt": {
|
||||||
|
"broker": "192.168.43.201",
|
||||||
|
"port": 1883,
|
||||||
|
"username": "",
|
||||||
|
"password": "",
|
||||||
|
"keepalive": 60
|
||||||
|
},
|
||||||
|
"monitoring": {
|
||||||
|
"enabled": true,
|
||||||
|
"health_interval_seconds": 5,
|
||||||
|
"heartbeat_interval_seconds": 60,
|
||||||
|
"max_restart_attempts": 3,
|
||||||
|
"restart_cooldown_seconds": 2
|
||||||
|
},
|
||||||
|
"logging": {
|
||||||
|
"level": "INFO",
|
||||||
|
"send_info_logs": false,
|
||||||
|
"console_output": true,
|
||||||
|
"local_log_file": "/var/log/infoscreen/watchdog.log"
|
||||||
|
},
|
||||||
|
"processes": {
|
||||||
|
"vlc": {
|
||||||
|
"http_port": 8080,
|
||||||
|
"http_password": "vlc_password"
|
||||||
|
},
|
||||||
|
"chromium": {
|
||||||
|
"debug_port": 9222
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Error Scenarios & Expected Behavior
|
||||||
|
|
||||||
|
### Scenario 1: VLC Crashes Mid-Video
|
||||||
|
```
|
||||||
|
1. Watchdog detects: process_status = "crashed"
|
||||||
|
2. Send ERROR log: "VLC process crashed"
|
||||||
|
3. Attempt 1: Restart VLC with same video, seek to last position
|
||||||
|
4. If success: Send INFO log "VLC restarted successfully"
|
||||||
|
5. If fail: Repeat 2 more times
|
||||||
|
6. After 3 failures: Send ERROR "Max restarts exceeded", show fallback
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 2: Network Timeout Loading Website
|
||||||
|
```
|
||||||
|
1. Chromium fails to load page (CDP reports error)
|
||||||
|
2. Send WARN log: "Page load timeout"
|
||||||
|
3. Attempt reload (Chromium refresh)
|
||||||
|
4. If success after 10s: Continue monitoring
|
||||||
|
5. If timeout again: Send ERROR, try restarting Chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 3: Display Powers Off (Hardware)
|
||||||
|
```
|
||||||
|
1. DPMS check detects display off
|
||||||
|
2. Send ERROR log: "Display powered off"
|
||||||
|
3. Attempt to wake display (xset dpms force on)
|
||||||
|
4. If success: Send INFO log
|
||||||
|
5. If fail: Hardware issue, alert admin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 4: High CPU Usage
|
||||||
|
```
|
||||||
|
1. CPU >80% for 60 seconds
|
||||||
|
2. Send WARN log: "High CPU usage: 85%"
|
||||||
|
3. Check if expected (e.g., video playback is normal)
|
||||||
|
4. If unexpected: investigate process causing it
|
||||||
|
5. If critical (>95%): consider restarting offending process
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Testing & Validation
|
||||||
|
|
||||||
|
### 12.1 Manual Tests (During Development)
|
||||||
|
|
||||||
|
**Test 1: Process Crash Simulation**
|
||||||
|
```bash
|
||||||
|
# Start video, then kill VLC manually
|
||||||
|
killall vlc
|
||||||
|
# Expected: ERROR log sent, automatic restart within 5 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test 2: MQTT Connectivity**
|
||||||
|
```bash
|
||||||
|
# Subscribe to all client topics on server
|
||||||
|
mosquitto_sub -h 192.168.43.201 -t "infoscreen/{uuid}/#" -v
|
||||||
|
# Expected: See heartbeat every 60s, health every 5s
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test 3: Log Levels**
|
||||||
|
```bash
|
||||||
|
# Trigger error condition and verify log appears in database
|
||||||
|
curl http://192.168.43.201:8000/api/client-logs/test
|
||||||
|
# Expected: See new log entry with correct level/message
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.2 Acceptance Criteria
|
||||||
|
|
||||||
|
✅ **Client must:**
|
||||||
|
1. Send heartbeat every 60 seconds without gaps
|
||||||
|
2. Send ERROR log within 5 seconds of process crash
|
||||||
|
3. Attempt automatic restart (max 3 times)
|
||||||
|
4. Report health metrics every 5 seconds
|
||||||
|
5. Survive MQTT broker restart (reconnect automatically)
|
||||||
|
6. Survive network interruption (buffer logs, send when reconnected)
|
||||||
|
7. Use correct timestamp format (ISO 8601 UTC)
|
||||||
|
8. Only send logs for real client UUID (FK constraint)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Python Libraries (Recommended)
|
||||||
|
|
||||||
|
**For process monitoring:**
|
||||||
|
- `psutil` - Cross-platform process and system utilities
|
||||||
|
|
||||||
|
**For MQTT:**
|
||||||
|
- `paho-mqtt` - Official MQTT client (use v2.x with Callback API v2)
|
||||||
|
|
||||||
|
**For VLC control:**
|
||||||
|
- `requests` - HTTP client for status queries
|
||||||
|
|
||||||
|
**For Chromium control:**
|
||||||
|
- `websocket-client` or `pychrome` - Chrome DevTools Protocol
|
||||||
|
|
||||||
|
**For datetime:**
|
||||||
|
- `datetime` (stdlib) - Use `datetime.now(timezone.utc).isoformat()`
|
||||||
|
|
||||||
|
**Example requirements.txt:**
|
||||||
|
```
|
||||||
|
paho-mqtt>=2.0.0
|
||||||
|
psutil>=5.9.0
|
||||||
|
requests>=2.31.0
|
||||||
|
python-dateutil>=2.8.0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Security Considerations
|
||||||
|
|
||||||
|
### 14.1 MQTT Security
|
||||||
|
- If broker requires auth, store credentials in config file with restricted permissions (`chmod 600`)
|
||||||
|
- Consider TLS/SSL for MQTT (port 8883) if on untrusted network
|
||||||
|
- Use unique client ID to prevent impersonation
|
||||||
|
|
||||||
|
### 14.2 Process Control APIs
|
||||||
|
- VLC HTTP password should be random, not default
|
||||||
|
- Chromium debug port should bind to `127.0.0.1` only (not `0.0.0.0`)
|
||||||
|
- Restrict file system access for media player processes
|
||||||
|
|
||||||
|
### 14.3 Log Content
|
||||||
|
- **Do not log:** Passwords, API keys, personal data
|
||||||
|
- **Sanitize:** File paths (strip user directories), URLs (remove query params with tokens)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Performance Targets
|
||||||
|
|
||||||
|
| Metric | Target | Acceptable | Critical |
|
||||||
|
|--------|--------|------------|----------|
|
||||||
|
| Health check interval | 5s | 10s | 30s |
|
||||||
|
| Crash detection time | <5s | <10s | <30s |
|
||||||
|
| Restart time | <10s | <20s | <60s |
|
||||||
|
| MQTT publish latency | <100ms | <500ms | <2s |
|
||||||
|
| CPU usage (watchdog) | <2% | <5% | <10% |
|
||||||
|
| RAM usage (watchdog) | <50MB | <100MB | <200MB |
|
||||||
|
| Log message size | <1KB | <10KB | <100KB |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Troubleshooting Guide (For Client Development)
|
||||||
|
|
||||||
|
### Issue: Logs not appearing in server database
|
||||||
|
**Check:**
|
||||||
|
1. Is MQTT broker reachable? (`mosquitto_pub` test from client)
|
||||||
|
2. Is client UUID correct and exists in `clients` table?
|
||||||
|
3. Is timestamp format correct (ISO 8601 with 'Z')?
|
||||||
|
4. Check server listener logs for errors
|
||||||
|
|
||||||
|
### Issue: Health metrics not updating
|
||||||
|
**Check:**
|
||||||
|
1. Is health loop running? (check watchdog service status)
|
||||||
|
2. Is MQTT connected? (check connection status in logs)
|
||||||
|
3. Is payload JSON valid? (use JSON validator)
|
||||||
|
|
||||||
|
### Issue: Process restarts in loop
|
||||||
|
**Check:**
|
||||||
|
1. Is media file/URL accessible?
|
||||||
|
2. Is process command correct? (test manually)
|
||||||
|
3. Check process exit code (crash reason)
|
||||||
|
4. Increase restart cooldown to avoid rapid loops
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Complete Message Flow Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Infoscreen Client │
|
||||||
|
│ │
|
||||||
|
│ Event Occurs: │
|
||||||
|
│ - Process crashed │
|
||||||
|
│ - High CPU usage │
|
||||||
|
│ - Content loaded │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────┐ │
|
||||||
|
│ │ Decision Logic │ │
|
||||||
|
│ │ - Is it ERROR?│ │
|
||||||
|
│ │ - Is it WARN? │ │
|
||||||
|
│ │ - Is it INFO? │ │
|
||||||
|
│ └────────┬───────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌────────────────────────────────┐ │
|
||||||
|
│ │ Build JSON Payload │ │
|
||||||
|
│ │ { │ │
|
||||||
|
│ │ "timestamp": "...", │ │
|
||||||
|
│ │ "message": "...", │ │
|
||||||
|
│ │ "context": {...} │ │
|
||||||
|
│ │ } │ │
|
||||||
|
│ └────────┬───────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌────────────────────────────────┐ │
|
||||||
|
│ │ MQTT Publish │ │
|
||||||
|
│ │ Topic: infoscreen/{uuid}/logs/error │
|
||||||
|
│ │ QoS: 1 │ │
|
||||||
|
│ └────────┬───────────────────────┘ │
|
||||||
|
└───────────┼──────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ TCP/IP (MQTT Protocol)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────┐
|
||||||
|
│ MQTT Broker │
|
||||||
|
│ (Mosquitto) │
|
||||||
|
└──────┬───────┘
|
||||||
|
│
|
||||||
|
│ Topic: infoscreen/+/logs/#
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ Listener Service │
|
||||||
|
│ (Python) │
|
||||||
|
│ │
|
||||||
|
│ - Parse JSON │
|
||||||
|
│ - Validate UUID │
|
||||||
|
│ - Store in database │
|
||||||
|
└──────┬───────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ MariaDB Database │
|
||||||
|
│ │
|
||||||
|
│ Table: client_logs │
|
||||||
|
│ - client_uuid │
|
||||||
|
│ - timestamp │
|
||||||
|
│ - level │
|
||||||
|
│ - message │
|
||||||
|
│ - context (JSON) │
|
||||||
|
└──────┬───────────────────────┘
|
||||||
|
│
|
||||||
|
│ SQL Query
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ API Server (Flask) │
|
||||||
|
│ │
|
||||||
|
│ GET /api/client-logs/{uuid}/logs
|
||||||
|
│ GET /api/client-logs/summary
|
||||||
|
└──────┬───────────────────────┘
|
||||||
|
│
|
||||||
|
│ HTTP/JSON
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ Dashboard (React) │
|
||||||
|
│ │
|
||||||
|
│ - Display logs │
|
||||||
|
│ - Filter by level │
|
||||||
|
│ - Show health status │
|
||||||
|
└───────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Quick Reference Card
|
||||||
|
|
||||||
|
### MQTT Topics Summary
|
||||||
|
```
|
||||||
|
infoscreen/{uuid}/logs/error → Critical failures
|
||||||
|
infoscreen/{uuid}/logs/warn → Non-critical issues
|
||||||
|
infoscreen/{uuid}/logs/info → Informational (dev mode)
|
||||||
|
infoscreen/{uuid}/health → Health metrics (every 5s)
|
||||||
|
infoscreen/{uuid}/heartbeat → Enhanced heartbeat (every 60s)
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON Timestamp Format
|
||||||
|
```python
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
timestamp = datetime.now(timezone.utc).isoformat()
|
||||||
|
# Output: "2026-03-10T07:30:00+00:00" or "2026-03-10T07:30:00Z"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Process Status Values
|
||||||
|
```
|
||||||
|
"running" - Process is alive and responding
|
||||||
|
"crashed" - Process terminated unexpectedly
|
||||||
|
"starting" - Process is launching (startup phase)
|
||||||
|
"stopped" - Process intentionally stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restart Logic
|
||||||
|
```
|
||||||
|
Max attempts: 3
|
||||||
|
Cooldown: 2 seconds between attempts
|
||||||
|
Reset: After 5 minutes of successful operation
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. Contact & Support
|
||||||
|
|
||||||
|
**Server API Documentation:**
|
||||||
|
- Base URL: `http://192.168.43.201:8000`
|
||||||
|
- Health check: `GET /health`
|
||||||
|
- Test logs: `GET /api/client-logs/test` (no auth)
|
||||||
|
- Full API docs: See `docs/archive/CLIENT_MONITORING_IMPLEMENTATION_GUIDE.md` on server
|
||||||
|
|
||||||
|
**MQTT Broker:**
|
||||||
|
- Host: `192.168.43.201`
|
||||||
|
- Port: `1883` (standard), `9001` (WebSocket)
|
||||||
|
- Test tool: `mosquitto_pub` / `mosquitto_sub`
|
||||||
|
|
||||||
|
**Database Schema:**
|
||||||
|
- Table: `client_logs`
|
||||||
|
- Foreign Key: `client_uuid` → `clients.uuid` (ON DELETE CASCADE)
|
||||||
|
- Constraint: UUID must exist in clients table before logging
|
||||||
|
|
||||||
|
**Server-Side Logs:**
|
||||||
|
```bash
|
||||||
|
# View listener logs (processes MQTT messages)
|
||||||
|
docker compose logs -f listener
|
||||||
|
|
||||||
|
# View server logs (API requests)
|
||||||
|
docker compose logs -f server
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. Appendix: Example Implementations
|
||||||
|
|
||||||
|
### A. Minimal Python Watchdog (Pseudocode)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import psutil
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
class MinimalWatchdog:
|
||||||
|
def __init__(self, client_uuid, mqtt_broker):
|
||||||
|
self.uuid = client_uuid
|
||||||
|
self.mqtt_client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2)
|
||||||
|
self.mqtt_client.connect(mqtt_broker, 1883, 60)
|
||||||
|
self.mqtt_client.loop_start()
|
||||||
|
|
||||||
|
self.expected_process = None
|
||||||
|
self.restart_attempts = 0
|
||||||
|
self.MAX_RESTARTS = 3
|
||||||
|
|
||||||
|
def send_log(self, level, message, context=None):
|
||||||
|
topic = f"infoscreen/{self.uuid}/logs/{level}"
|
||||||
|
payload = {
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"message": message,
|
||||||
|
"context": context or {}
|
||||||
|
}
|
||||||
|
self.mqtt_client.publish(topic, json.dumps(payload), qos=1)
|
||||||
|
|
||||||
|
def is_process_running(self, process_name):
|
||||||
|
for proc in psutil.process_iter(['name']):
|
||||||
|
if process_name in proc.info['name']:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def monitor_loop(self):
|
||||||
|
while True:
|
||||||
|
if self.expected_process:
|
||||||
|
if not self.is_process_running(self.expected_process):
|
||||||
|
self.send_log("error", f"{self.expected_process} crashed")
|
||||||
|
if self.restart_attempts < self.MAX_RESTARTS:
|
||||||
|
self.restart_process()
|
||||||
|
else:
|
||||||
|
self.send_log("error", "Max restarts exceeded")
|
||||||
|
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
# Usage:
|
||||||
|
watchdog = MinimalWatchdog("9b8d1856-ff34-4864-a726-12de072d0f77", "192.168.43.201")
|
||||||
|
watchdog.expected_process = "vlc"
|
||||||
|
watchdog.monitor_loop()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**END OF SPECIFICATION**
|
||||||
|
|
||||||
|
Questions? Refer to:
|
||||||
|
- `docs/archive/CLIENT_MONITORING_IMPLEMENTATION_GUIDE.md` (server repo)
|
||||||
|
- Server API: `http://192.168.43.201:8000/api/client-logs/test`
|
||||||
|
- MQTT test: `mosquitto_sub -h 192.168.43.201 -t infoscreen/#`
|
||||||
209
DATABASE_GUIDE.md
Normal file
209
DATABASE_GUIDE.md
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
# Database Initialization and Management Guide
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Your database has been successfully initialized! Here's what you need to know:
|
||||||
|
|
||||||
|
### ✅ Current Status
|
||||||
|
- **Database**: MariaDB 11.2 running in Docker container `infoscreen-db`
|
||||||
|
- **Schema**: Up to date (check with `alembic current` in `server/`)
|
||||||
|
- **Default Data**: Admin user and client group created
|
||||||
|
- **Academic Periods**: Austrian school years 2024/25 (active), 2025/26, 2026/27
|
||||||
|
|
||||||
|
### 🔐 Default Credentials
|
||||||
|
- **Admin Username**: `infoscreen_admin`
|
||||||
|
- **Admin Password**: Check your `.env` file for `DEFAULT_ADMIN_PASSWORD`
|
||||||
|
- **Database User**: `infoscreen_admin`
|
||||||
|
- **Database Name**: `infoscreen_by_taa`
|
||||||
|
|
||||||
|
## Database Management Commands
|
||||||
|
|
||||||
|
### Initialize/Reinitialize Database
|
||||||
|
```bash
|
||||||
|
cd /workspace/server
|
||||||
|
python initialize_database.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Migration Status
|
||||||
|
```bash
|
||||||
|
cd /workspace/server
|
||||||
|
alembic current
|
||||||
|
alembic history --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Migrations Manually
|
||||||
|
```bash
|
||||||
|
cd /workspace/server
|
||||||
|
alembic upgrade head # Apply all pending migrations
|
||||||
|
alembic upgrade +1 # Apply next migration
|
||||||
|
alembic downgrade -1 # Rollback one migration
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create New Migration
|
||||||
|
```bash
|
||||||
|
cd /workspace/server
|
||||||
|
alembic revision --autogenerate -m "Description of changes"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Connection Test
|
||||||
|
```bash
|
||||||
|
cd /workspace/server
|
||||||
|
python -c "
|
||||||
|
from database import Session
|
||||||
|
session = Session()
|
||||||
|
print('✅ Database connection successful')
|
||||||
|
session.close()
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Initialization Scripts
|
||||||
|
|
||||||
|
### Core Scripts (recommended order):
|
||||||
|
1. **`alembic upgrade head`** - Apply database schema migrations
|
||||||
|
2. **`init_defaults.py`** - Create default user groups and admin user
|
||||||
|
3. **`init_academic_periods.py`** - Set up Austrian school year periods
|
||||||
|
|
||||||
|
### All-in-One Script:
|
||||||
|
- **`initialize_database.py`** - Complete database initialization (runs all above scripts)
|
||||||
|
|
||||||
|
### Development/Testing Scripts:
|
||||||
|
- **`dummy_clients.py`** - Creates test client data for development
|
||||||
|
- **`dummy_events.py`** - Creates test event data for development
|
||||||
|
- **`sync_existing_clients.py`** - One-time MQTT sync for existing clients
|
||||||
|
|
||||||
|
## Database Schema Overview
|
||||||
|
|
||||||
|
### Main Tables:
|
||||||
|
- **`users`** - User authentication and roles
|
||||||
|
- **`clients`** - Registered client devices
|
||||||
|
- **`client_groups`** - Client organization groups
|
||||||
|
- **`events`** - Scheduled events and presentations
|
||||||
|
- **`event_media`** - Media files for events
|
||||||
|
- **`conversions`** - File conversion jobs (PPT → PDF)
|
||||||
|
- **`academic_periods`** - School year/semester management
|
||||||
|
- **`school_holidays`** - Holiday calendar
|
||||||
|
- **`event_exceptions`** - Overrides and skips for recurring events (per occurrence)
|
||||||
|
- **`system_settings`** - Key–value store for global settings
|
||||||
|
- **`alembic_version`** - Migration tracking
|
||||||
|
|
||||||
|
### Key details and relationships
|
||||||
|
|
||||||
|
- Users (`users`)
|
||||||
|
- Fields: `username` (unique), `password_hash`, `role` (enum: user|editor|admin|superadmin), `is_active`
|
||||||
|
|
||||||
|
- Client groups (`client_groups`)
|
||||||
|
- Fields: `name` (unique), `description`, `is_active`
|
||||||
|
|
||||||
|
- Clients (`clients`)
|
||||||
|
- Fields: `uuid` (PK), network/device metadata, `group_id` (FK→client_groups, default 1), `last_alive` (updated on heartbeat), `is_active`
|
||||||
|
|
||||||
|
- Academic periods (`academic_periods`)
|
||||||
|
- Fields: `name` (unique), optional `display_name`, `start_date`, `end_date`, `period_type` (enum: schuljahr|semester|trimester), `is_active` (at most one should be active)
|
||||||
|
- Indexes: `is_active`, dates
|
||||||
|
|
||||||
|
- Event media (`event_media`)
|
||||||
|
- Fields: `media_type` (enum, see below), `url`, optional `file_path`, optional `message_content`, optional `academic_period_id`
|
||||||
|
- Used by events of types: presentation, video, website, message, other
|
||||||
|
|
||||||
|
- Events (`events`)
|
||||||
|
- Core: `group_id` (FK), optional `academic_period_id` (FK), `title`, optional `description`, `start`, `end`, `event_type` (enum), optional `event_media_id` (FK)
|
||||||
|
- Presentation/video extras: `autoplay`, `loop`, `volume`, `slideshow_interval`, `page_progress`, `auto_progress`
|
||||||
|
- Recurrence: `recurrence_rule` (RFC 5545 RRULE), `recurrence_end`, `skip_holidays` (bool)
|
||||||
|
- Audit/state: `created_by` (FK→users), `updated_by` (FK→users), `is_active`
|
||||||
|
- Indexes: `start`, `end`, `recurrence_rule`, `recurrence_end`
|
||||||
|
- Relationships: `event_media`, `academic_period`, `exceptions` (one-to-many to `event_exceptions` with cascade delete)
|
||||||
|
|
||||||
|
- Event exceptions (`event_exceptions`)
|
||||||
|
- Purpose: track per-occurrence skips or overrides for a recurring master event
|
||||||
|
- Fields: `event_id` (FK→events, ondelete CASCADE), `exception_date` (Date), `is_skipped`, optional overrides (`title`, `description`, `start`, `end`)
|
||||||
|
|
||||||
|
- School holidays (`school_holidays`)
|
||||||
|
- Unique: (`name`, `start_date`, `end_date`, `region`)
|
||||||
|
- Used in combination with `events.skip_holidays`
|
||||||
|
|
||||||
|
- Conversions (`conversions`)
|
||||||
|
- Purpose: track PPT/PPTX/ODP → PDF processing
|
||||||
|
- Fields: `source_event_media_id` (FK→event_media, ondelete CASCADE), `target_format`, `target_path`, `status` (enum), `file_hash`, timestamps, `error_message`
|
||||||
|
- Indexes: (`source_event_media_id`, `target_format`), (`status`, `target_format`)
|
||||||
|
- Unique: (`source_event_media_id`, `target_format`, `file_hash`) — idempotency per content
|
||||||
|
|
||||||
|
- System settings (`system_settings`)
|
||||||
|
- Key–value store: `key` (PK), `value`, optional `description`, `updated_at`
|
||||||
|
- Notable keys used by the app: `presentation_interval`, `presentation_page_progress`, `presentation_auto_progress`
|
||||||
|
|
||||||
|
### Enums (reference)
|
||||||
|
|
||||||
|
- UserRole: `user`, `editor`, `admin`, `superadmin`
|
||||||
|
- AcademicPeriodType: `schuljahr`, `semester`, `trimester`
|
||||||
|
- EventType: `presentation`, `website`, `video`, `message`, `other`, `webuntis`
|
||||||
|
- MediaType: `pdf`, `ppt`, `pptx`, `odp`, `mp4`, `avi`, `mkv`, `mov`, `wmv`, `flv`, `webm`, `mpg`, `mpeg`, `ogv`, `jpg`, `jpeg`, `png`, `gif`, `bmp`, `tiff`, `svg`, `html`, `website`
|
||||||
|
- ConversionStatus: `pending`, `processing`, `ready`, `failed`
|
||||||
|
|
||||||
|
### Timezones, recurrence, and holidays
|
||||||
|
|
||||||
|
- All timestamps are stored/compared as timezone-aware UTC. Any naive datetimes are normalized to UTC before comparisons.
|
||||||
|
- Recurrence is represented on events via `recurrence_rule` (RFC 5545 RRULE) and `recurrence_end`. Do not pre-expand series in the DB.
|
||||||
|
- Per-occurrence exclusions/overrides are stored in `event_exceptions`. The API also emits EXDATE tokens matching occurrence start times (UTC) so the frontend can exclude instances natively.
|
||||||
|
- When `skip_holidays` is true, occurrences that fall on school holidays are excluded via corresponding `event_exceptions`.
|
||||||
|
|
||||||
|
### Environment Variables:
|
||||||
|
```bash
|
||||||
|
DB_CONN=mysql+pymysql://infoscreen_admin:KqtpM7wmNdM1DamFKs@db/infoscreen_by_taa
|
||||||
|
DB_USER=infoscreen_admin
|
||||||
|
DB_PASSWORD=KqtpM7wmNdM1DamFKs
|
||||||
|
DB_NAME=infoscreen_by_taa
|
||||||
|
DB_HOST=db
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Database Connection Issues:
|
||||||
|
```bash
|
||||||
|
# Check if database container is running
|
||||||
|
docker ps | grep db
|
||||||
|
|
||||||
|
# Check database logs
|
||||||
|
docker logs infoscreen-db
|
||||||
|
|
||||||
|
# Test direct connection
|
||||||
|
docker exec -it infoscreen-db mysql -u infoscreen_admin -p infoscreen_by_taa
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration Issues:
|
||||||
|
```bash
|
||||||
|
# Check current state
|
||||||
|
cd /workspace/server && alembic current
|
||||||
|
|
||||||
|
# Show migration history
|
||||||
|
cd /workspace/server && alembic history
|
||||||
|
|
||||||
|
# Show pending migrations
|
||||||
|
cd /workspace/server && alembic show head
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reset Database (⚠️ DESTRUCTIVE):
|
||||||
|
```bash
|
||||||
|
# Stop services
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Remove database volume
|
||||||
|
docker volume rm infoscreen_2025_db-data
|
||||||
|
|
||||||
|
# Restart and reinitialize
|
||||||
|
docker-compose up -d db
|
||||||
|
cd /workspace/server && python initialize_database.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
The production setup in `docker-compose.prod.yml` includes automatic database initialization:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
command: >
|
||||||
|
bash -c "alembic -c /app/server/alembic.ini upgrade head &&
|
||||||
|
python /app/server/init_defaults.py &&
|
||||||
|
exec gunicorn server.wsgi:app --bind 0.0.0.0:8000"
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures the database is properly initialized on every deployment.
|
||||||
56
DEV-CHANGELOG.md
Normal file
56
DEV-CHANGELOG.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# DEV-CHANGELOG
|
||||||
|
|
||||||
|
This changelog tracks all changes made in the development workspace, including internal, experimental, and in-progress updates. Entries here may not be reflected in public releases or the user-facing changelog.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unreleased (development workspace)
|
||||||
|
- Crash detection API: Added `GET /api/clients/crashed` returning clients with `process_status=crashed` or stale heartbeat; includes `crash_reason` field (`process_crashed` | `heartbeat_stale`).
|
||||||
|
- Crash auto-recovery (scheduler): Feature-flagged loop (`CRASH_RECOVERY_ENABLED`) scans crash candidates, issues `reboot_host` command, publishes to primary + compat MQTT topics; lockout window and expiry configurable via env.
|
||||||
|
- Command expiry sweep (scheduler): Unconditional per-cycle sweep in `sweep_expired_commands()` marks non-terminal `ClientCommand` rows past `expires_at` as `expired`.
|
||||||
|
- `restart_app` action registered in `server/routes/clients.py` API action map; sends same command lifecycle as `reboot_host`; safety lockout covers both actions.
|
||||||
|
- `service_failed` listener: subscribes to `infoscreen/+/service_failed` on every connect; persists `service_failed_at` + `service_failed_unit` to `Client`; empty payload (retain clear) silently ignored.
|
||||||
|
- Broker connection health: Listener health handler now extracts `broker_connection.reconnect_count` + `broker_connection.last_disconnect_at` and persists to `Client`.
|
||||||
|
- DB migration `b1c2d3e4f5a6`: adds `service_failed_at`, `service_failed_unit`, `mqtt_reconnect_count`, `mqtt_last_disconnect_at` to `clients` table.
|
||||||
|
- Model update: `models/models.py` Client class updated with all four new columns.
|
||||||
|
- `GET /api/clients/service_failed`: lists clients with `service_failed_at` set, admin-or-higher gated.
|
||||||
|
- `POST /api/clients/<uuid>/clear_service_failed`: clears DB flag and publishes empty retained MQTT to `infoscreen/{uuid}/service_failed`.
|
||||||
|
- Monitoring overview includes `mqtt_reconnect_count` + `mqtt_last_disconnect_at` per client.
|
||||||
|
- Frontend monitoring: orange service-failed alert panel (hidden when count=0), auto-refresh 15s, per-row Quittieren action.
|
||||||
|
- Frontend monitoring: client detail now shows MQTT reconnect count + last disconnect timestamp.
|
||||||
|
- Frontend types: `ServiceFailedClient`, `ServiceFailedClientsResponse`; helpers `fetchServiceFailedClients()`, `clearServiceFailed()` added to `dashboard/src/apiClients.ts`.
|
||||||
|
- `MQTT_EVENT_PAYLOAD_GUIDE.md`: added `service_failed` topic contract.
|
||||||
|
- MQTT auth hardening: Listener and scheduler now connect to broker with env-configured credentials (`MQTT_BROKER_HOST`, `MQTT_BROKER_PORT`, `MQTT_USER`, `MQTT_PASSWORD`) instead of anonymous fixed host/port defaults; optional TLS env toggles added in code path (`MQTT_TLS_*`).
|
||||||
|
- Broker auth enforcement: `mosquitto/config/mosquitto.conf` now disables anonymous access and enables password-file authentication. `docker-compose.yml` MQTT service now bootstraps/update password entries from env (`MQTT_USER`/`MQTT_PASSWORD`, optional canary user) before starting broker.
|
||||||
|
- Compose wiring: Added MQTT credential env propagation for listener/scheduler in both base and dev override compose files and switched MQTT healthcheck publish to authenticated mode.
|
||||||
|
- Backend implementation: Introduced client command lifecycle foundation for remote control in `server/routes/clients.py` with command persistence (`ClientCommand`), schema-based MQTT publish to `infoscreen/{uuid}/commands` (QoS1, non-retained), new endpoints `POST /api/clients/<uuid>/shutdown` and `GET /api/clients/commands/<command_id>`, and restart safety lockout (`blocked_safety` after 3 restarts in 15 minutes). Added migration `server/alembic/versions/aa12bb34cc56_add_client_commands_table.py` and model updates in `models/models.py`. Restart path keeps transitional legacy MQTT publish to `clients/{uuid}/restart` for compatibility.
|
||||||
|
- Listener integration: `listener/listener.py` now subscribes to `infoscreen/+/commands/ack` and updates command lifecycle states from client ACK payloads (`accepted`, `execution_started`, `completed`, `failed`).
|
||||||
|
- Frontend API client prep: Extended `dashboard/src/apiClients.ts` with `ClientCommand` typing and helper calls for lifecycle consumption (`shutdownClient`, `fetchClientCommandStatus`), and updated `restartClient` to accept optional reason payload.
|
||||||
|
- Contract freeze clarification: implementation-plan docs now explicitly freeze canonical MQTT topics (`infoscreen/{uuid}/commands`, `infoscreen/{uuid}/commands/ack`) and JSON schemas with examples; added transitional singular-topic compatibility aliases (`infoscreen/{uuid}/command`, `infoscreen/{uuid}/command/ack`) in server publish and listener ingest.
|
||||||
|
- Action value canonicalization: command payload actions are now frozen as host-level values (`reboot_host`, `shutdown_host`). API endpoint mapping is explicit (`/restart` -> `reboot_host`, `/shutdown` -> `shutdown_host`), and docs/examples were updated to remove `restart` payload ambiguity.
|
||||||
|
- Client helper snippets: Added frozen payload validation artifacts `implementation-plans/reboot-command-payload-schemas.md` and `implementation-plans/reboot-command-payload-schemas.json` (copy-ready snippets plus machine-validated JSON Schema).
|
||||||
|
- Documentation alignment: Added active reboot implementation handoff docs under `implementation-plans/` and linked them in `README.md` for immediate cross-team access (`reboot-implementation-handoff-share.md`, `reboot-implementation-handoff-client-team.md`, `reboot-kickoff-summary.md`).
|
||||||
|
- Programminfo GUI regression/fix: `dashboard/public/program-info.json` could not be loaded in Programminfo menu due to invalid JSON in the new alpha.16 changelog line (malformed quote in a text entry). Fixed JSON entry and verified file parses correctly again.
|
||||||
|
- Dashboard holiday banner fix: `dashboard/src/dashboard.tsx` — `loadHolidayStatus` now uses a stable `useCallback` with empty deps, preventing repeated re-creation on render. `useEffect` depends only on the stable callback reference.
|
||||||
|
- Dashboard Syncfusion stale-render fix: `MessageComponent` in the holiday banner now receives `key={`${severity}:${text}`}` to force remount when severity or text changes; without this Syncfusion cached stale DOM and the banner did not update reactively.
|
||||||
|
- Dashboard German text: Replaced transliterated forms (ae/oe/ue) with correct Umlauts throughout visible dashboard UI strings — `Präsentation`, `für`, `prüfen`, `Ferienüberschneidungen`, `verfügbar`, `Vorfälle`, `Ausfälle`.
|
||||||
|
- TV power intent (Phase 1): Scheduler publishes retained QoS1 group-level intents to `infoscreen/groups/{group_id}/power/intent` with transition+heartbeat semantics, startup/reconnect republish, and poll-based expiry (`max(3 × poll_interval_sec, 90s)`).
|
||||||
|
- TV power validation: Added unit/integration/canary coverage in `scheduler/test_power_intent_utils.py`, `scheduler/test_power_intent_scheduler.py`, and `test_power_intent_canary.py`.
|
||||||
|
- Monitoring system completion: End-to-end monitoring pipeline is active (MQTT logs/health → listener persistence → monitoring APIs → superadmin dashboard).
|
||||||
|
- Monitoring API: Added/active endpoints `GET /api/client-logs/monitoring-overview` and `GET /api/client-logs/recent-errors`; per-client logs via `GET /api/client-logs/<uuid>/logs`.
|
||||||
|
- Dashboard monitoring UI: Superadmin monitoring page is integrated and displays client health status, screenshots, process metadata, and recent error activity.
|
||||||
|
- Bugfix: Presentation flags `page_progress` and `auto_progress` now persist reliably across create/update and detached-occurrence flows.
|
||||||
|
- Frontend (Settings → Events): Added Presentations defaults (slideshow interval, page-progress, auto-progress) with load/save via `/api/system-settings`; UI uses Syncfusion controls.
|
||||||
|
- Backend defaults: Seeded `presentation_interval` ("10"), `presentation_page_progress` ("true"), `presentation_auto_progress` ("true") in `server/init_defaults.py` when missing.
|
||||||
|
- Data model: Added per-event fields `page_progress` and `auto_progress` on `Event`; Alembic migration applied successfully.
|
||||||
|
- Event modal (dashboard): Extended to show and persist presentation `pageProgress`/`autoProgress`; applies system defaults on create and preserves per-event values on edit; payload includes `page_progress`, `auto_progress`, and `slideshow_interval`.
|
||||||
|
- Scheduler behavior: 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`.
|
||||||
|
- Recurrence handling: Still queries a 7‑day window to expand recurring events and apply exceptions; recurring events only deactivate after `recurrence_end` (UNTIL).
|
||||||
|
- Logging: Temporarily added filter diagnostics during debugging; removed verbose logs after verification.
|
||||||
|
- WebUntis event type: Implemented new `webuntis` type. Event creation resolves URL from system `supplement_table_url`; returns 400 if not configured. WebUntis behaves like Website on clients (shared website payload).
|
||||||
|
- Settings consolidation: Removed separate `webuntis_url` (if present during dev); WebUntis and Vertretungsplan share `supplement_table_url`. Removed `/api/system-settings/webuntis-url` endpoints; use `/api/system-settings/supplement-table`.
|
||||||
|
- Scheduler payloads: Added top-level `event_type` for all events; introduced unified nested `website` payload for both `website` and `webuntis` events: `{ "type": "browser", "url": "…" }`.
|
||||||
|
- Frontend: Program info bumped to `2025.1.0-alpha.13`; changelog includes WebUntis/Website unification and settings update. Event modal shows no per-event URL for WebUntis.
|
||||||
|
- Documentation: Added `MQTT_EVENT_PAYLOAD_GUIDE.md` and `WEBUNTIS_EVENT_IMPLEMENTATION.md`. Updated `.github/copilot-instructions.md` and `README.md` for unified Website/WebUntis handling and system settings usage.
|
||||||
|
|
||||||
|
Note: These changes are available in the development environment and may be included in future releases. For released changes, see TECH-CHANGELOG.md.
|
||||||
328
FRONTEND_DESIGN_RULES.md
Normal file
328
FRONTEND_DESIGN_RULES.md
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
# Frontend Design Rules
|
||||||
|
|
||||||
|
This file is the single source of truth for UI implementation conventions in the dashboard (`dashboard/src/`).
|
||||||
|
It applies to all feature work, including new pages, settings tabs, dialogs, and management surfaces.
|
||||||
|
|
||||||
|
When proposing or implementing frontend changes, follow these rules unless a specific exception is documented below.
|
||||||
|
This file should be updated whenever a new Syncfusion component is adopted, a color or pattern changes, or an exception is ratified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Component Library — Syncfusion First
|
||||||
|
|
||||||
|
Use Syncfusion components as the default choice for every UI element.
|
||||||
|
The project uses the Syncfusion Material3 theme, registered globally in `dashboard/src/main.tsx`.
|
||||||
|
|
||||||
|
The following CSS packages are imported there and cover all components currently in use:
|
||||||
|
`base`, `navigations`, `buttons`, `inputs`, `dropdowns`, `popups`, `kanban`, `grids`, `schedule`, `filemanager`, `notifications`, `layouts`, `lists`, `calendars`, `splitbuttons`, `icons`.
|
||||||
|
When adding a new Syncfusion component, add its CSS import here — and add the new npm package to `optimizeDeps.include` in `vite.config.ts` to avoid Vite import-analysis errors in development.
|
||||||
|
|
||||||
|
Use non-Syncfusion elements only when:
|
||||||
|
- The Syncfusion equivalent does not exist (e.g., native `<input type="file">` for file upload)
|
||||||
|
- The Syncfusion component would require significantly more code than a simple HTML element for purely read-only or structural content (e.g., `<ul>/<li>` for plain lists)
|
||||||
|
- A layout-only structure is needed (a wrapper `<div>` for spacing is fine)
|
||||||
|
|
||||||
|
**Never** use `window.confirm()` for destructive action confirmations — use `DialogComponent` instead.
|
||||||
|
`window.confirm()` exists in one place in `dashboard.tsx` (bulk restart) and is considered a deprecated pattern to avoid.
|
||||||
|
|
||||||
|
Do not introduce Tailwind utility classes — Tailwind has been removed from the project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Component Defaults by Purpose
|
||||||
|
|
||||||
|
| Purpose | Component | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Navigation tabs | `TabComponent` + `TabItemDirective` | `heightAdjustMode="Auto"`, controlled with `selectedItem` state |
|
||||||
|
| Data list or table | `GridComponent` | `allowPaging`, `allowSorting`, custom `template` for status/actions |
|
||||||
|
| Paginated list | `PagerComponent` | When a full grid is too heavy; default page size 5 or 10 |
|
||||||
|
| Text input | `TextBoxComponent` | Use `cssClass="e-outline"` on form-heavy sections |
|
||||||
|
| Numeric input | `NumericTextBoxComponent` | Always set `min`, `max`, `step`, `format` |
|
||||||
|
| Single select | `DropDownListComponent` | Always set `fields={{ text, value }}`; do **not** add `cssClass="e-outline"` — only `TextBoxComponent` uses outline style |
|
||||||
|
| Boolean toggle | `CheckBoxComponent` | Use `label` prop, handle via `change` callback |
|
||||||
|
| Buttons | `ButtonComponent` | See section 4 |
|
||||||
|
| Modal dialogs | `DialogComponent` | `isModal={true}`, `showCloseIcon={true}`, footer with Cancel + primary |
|
||||||
|
| Notifications | `ToastComponent` | Positioned `{ X: 'Right', Y: 'Top' }`, 3000ms timeout by default |
|
||||||
|
| Inline info/error | `MessageComponent` | Use `severity` prop: `'Error'`, `'Warning'`, `'Info'`, `'Success'` |
|
||||||
|
| Status/role badges | Plain `<span>` with inline style | See section 6 for convention |
|
||||||
|
| Timeline/schedule | `ScheduleComponent` | Used for resource timeline views; see `ressourcen.tsx` |
|
||||||
|
| File management | `FileManagerComponent` | Used on the Media page for upload and organisation |
|
||||||
|
| Drag-drop board | `KanbanComponent` | Used on the Groups page; retain for drag-drop boards |
|
||||||
|
| User action menu | `DropDownButtonComponent` (`@syncfusion/ej2-react-splitbuttons`) | Used for header user menu; add to `optimizeDeps.include` in `vite.config.ts` |
|
||||||
|
| File upload | Native `<input type="file">` | No Syncfusion equivalent for raw file input |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Layout and Card Structure
|
||||||
|
|
||||||
|
Every settings tab section starts with a `<div style={{ padding: 20 }}>` wrapper.
|
||||||
|
Content blocks use Syncfusion card classes:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div className="e-card">
|
||||||
|
<div className="e-card-header">
|
||||||
|
<div className="e-card-header-caption">
|
||||||
|
<div className="e-card-header-title">Title</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card-content">
|
||||||
|
{/* content */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple cards in the same tab section use `style={{ marginBottom: 20 }}` between them.
|
||||||
|
|
||||||
|
For full-page views (not inside a settings tab), the top section follows this pattern:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 24, fontWeight: 600 }}>Page title</h2>
|
||||||
|
<p style={{ margin: '8px 0 0 0', color: '#6c757d' }}>Subtitle or description</p>
|
||||||
|
</div>
|
||||||
|
<ButtonComponent cssClass="e-success" iconCss="e-icons e-plus">New item</ButtonComponent>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Buttons
|
||||||
|
|
||||||
|
| Variant | `cssClass` | When to use |
|
||||||
|
|---|---|---|
|
||||||
|
| Primary action (save, confirm) | `e-primary` | Main save or confirm in forms and dialogs |
|
||||||
|
| Create / add new | `e-success` + `iconCss="e-icons e-plus"` | Top-level create action in page header |
|
||||||
|
| Destructive (delete, archive) | `e-flat e-danger` | Row actions and destructive dialog confirm |
|
||||||
|
| Secondary / cancel | `e-flat` | Cancel in dialog footer, low-priority options |
|
||||||
|
| Info / edit | `e-flat e-primary` or `e-flat e-info` | Row-level edit and info actions |
|
||||||
|
| Outline secondary | `e-outline` | Secondary actions needing a visible border (e.g., preview URL) |
|
||||||
|
|
||||||
|
All async action buttons must be `disabled` during the in-flight operation: `disabled={isBusy}`.
|
||||||
|
Button text must change to indicate the pending state: `Speichere…`, `Erstelle...`, `Archiviere…`, `Lösche...`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Dialogs
|
||||||
|
|
||||||
|
All create, edit, and destructive action dialogs use `DialogComponent`:
|
||||||
|
- `isModal={true}`
|
||||||
|
- `showCloseIcon={true}`
|
||||||
|
- `width="500px"` for forms (wider if tabular data is shown inside)
|
||||||
|
- `header` prop with specific context text (include item name where applicable)
|
||||||
|
- `footerTemplate` always has at minimum: Cancel (`e-flat`) + primary action (`e-primary`)
|
||||||
|
- Dialog body wrapped in `<div style={{ padding: 16 }}>`
|
||||||
|
- All fields disabled when `formBusy` is true
|
||||||
|
|
||||||
|
For destructive confirmations (archive, delete), the dialog body must clearly explain what will happen and whether it is reversible.
|
||||||
|
|
||||||
|
For blocked actions, use `MessageComponent` with `severity="Warning"` or `severity="Error"` inside the dialog body to show exact blocker details (e.g., linked event count, recurrence spillover).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Status and Type Badges
|
||||||
|
|
||||||
|
Plain `<span>` badges with inline style — no external CSS classes needed:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<span style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
backgroundColor: color,
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
display: 'inline-block',
|
||||||
|
}}>
|
||||||
|
Label
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
See section 12 for the fixed color palette.
|
||||||
|
|
||||||
|
**Icon conventions**: Use inline SVG or icon font classes for small visual indicators next to text. Established precedents:
|
||||||
|
- Skip-holidays events render a TentTree icon immediately to the left of the main event-type icon; **always black** (`color: 'black'` or no color override).
|
||||||
|
- Recurring events rely on Syncfusion's native lower-right recurrence badge — do not add a custom recurrence icon.
|
||||||
|
|
||||||
|
**Role badge color mapping** (established in `users.tsx`; apply consistently for any role display):
|
||||||
|
|
||||||
|
| Role | Color |
|
||||||
|
|---|---|
|
||||||
|
| user | `#6c757d` (neutral gray) |
|
||||||
|
| editor | `#0d6efd` (info blue) |
|
||||||
|
| admin | `#28a745` (success green) |
|
||||||
|
| superadmin | `#dc3545` (danger red) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Toast Notifications
|
||||||
|
|
||||||
|
Use a component-local `ToastComponent` with a `ref`:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const toastRef = React.useRef<ToastComponent>(null);
|
||||||
|
// ...
|
||||||
|
<ToastComponent ref={toastRef} position={{ X: 'Right', Y: 'Top' }} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Default `timeOut: 3000`. Use `4000` for messages that need more reading time.
|
||||||
|
|
||||||
|
CSS class conventions:
|
||||||
|
- `e-toast-success` — successful operations
|
||||||
|
- `e-toast-danger` — errors
|
||||||
|
- `e-toast-warning` — non-critical issues or partial results
|
||||||
|
- `e-toast-info` — neutral informational messages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Form Fields
|
||||||
|
|
||||||
|
All form labels:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
|
||||||
|
Field label *
|
||||||
|
</label>
|
||||||
|
```
|
||||||
|
|
||||||
|
Help/hint text below a field:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div style={{ fontSize: '12px', color: '#666', marginTop: 4 }}>
|
||||||
|
Hint text here.
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Empty state inside a card:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div style={{ fontSize: '14px', color: '#666' }}>Keine Einträge vorhanden.</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Vertical spacing between field groups: `marginBottom: 16`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Tab Structure
|
||||||
|
|
||||||
|
Top-level and nested tabs use controlled `selectedItem` state with separate index variables per tab level.
|
||||||
|
This prevents sub-tab resets when parent state changes.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const [academicTabIndex, setAcademicTabIndex] = React.useState(0);
|
||||||
|
|
||||||
|
<TabComponent
|
||||||
|
heightAdjustMode="Auto"
|
||||||
|
selectedItem={academicTabIndex}
|
||||||
|
selected={(e: TabSelectedEvent) => setAcademicTabIndex(e.selectedIndex ?? 0)}
|
||||||
|
>
|
||||||
|
<TabItemsDirective>
|
||||||
|
<TabItemDirective header={{ text: '🗂️ Perioden' }} content={AcademicPeriodsContent} />
|
||||||
|
<TabItemDirective header={{ text: '📥 Import & Liste' }} content={HolidaysImportAndListContent} />
|
||||||
|
</TabItemsDirective>
|
||||||
|
</TabComponent>
|
||||||
|
```
|
||||||
|
|
||||||
|
Tab header text uses an emoji prefix followed by a German label, consistent with all existing tabs.
|
||||||
|
Each nested tab level has its own separate index state variable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Statistics Summary Cards
|
||||||
|
|
||||||
|
Used above grids and lists to show aggregate counts:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div style={{ marginBottom: 24, display: 'flex', gap: 16 }}>
|
||||||
|
<div className="e-card" style={{ flex: 1, padding: 16 }}>
|
||||||
|
<div style={{ fontSize: 14, color: '#6c757d', marginBottom: 4 }}>Label</div>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 600, color: '#28a745' }}>42</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Inline Warning Messages
|
||||||
|
|
||||||
|
For important warnings inside forms or dialogs:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div style={{
|
||||||
|
padding: 12,
|
||||||
|
backgroundColor: '#fff3cd',
|
||||||
|
border: '1px solid #ffc107',
|
||||||
|
borderRadius: 4,
|
||||||
|
marginBottom: 16,
|
||||||
|
fontSize: 14,
|
||||||
|
}}>
|
||||||
|
⚠️ Warning message text here.
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
For structured in-page errors or access-denied states, use `MessageComponent`:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<MessageComponent severity="Error" content="Fehlermeldung" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Color Palette
|
||||||
|
|
||||||
|
Only the following colors are used in status and UI elements across the dashboard.
|
||||||
|
Do not introduce new colors for new components.
|
||||||
|
|
||||||
|
| Use | Color |
|
||||||
|
|---|---|
|
||||||
|
| Success / active / online | `#28a745` |
|
||||||
|
| Danger / delete / offline | `#dc3545` |
|
||||||
|
| Warning / partial | `#f39c12` |
|
||||||
|
| Info / edit blue | `#0d6efd` |
|
||||||
|
| Neutral / archived / subtitle | `#6c757d` |
|
||||||
|
| Help / secondary text | `#666` |
|
||||||
|
| Inactive/muted | `#868e96` |
|
||||||
|
| Warning background | `#fff3cd` |
|
||||||
|
| Warning border | `#ffc107` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Dedicated CSS Files
|
||||||
|
|
||||||
|
Use inline styles for settings tab sections and simpler pages.
|
||||||
|
Only create a dedicated `.css` file if the component requires complex layout, custom animations, or selector-based styles that are not feasible with inline styles.
|
||||||
|
|
||||||
|
Existing precedents: `monitoring.css`, `ressourcen.css`.
|
||||||
|
|
||||||
|
Do not use Tailwind — it has been removed from the project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Loading States
|
||||||
|
|
||||||
|
For full-page loading, use a simple centered placeholder:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<div style={{ textAlign: 'center', padding: 40 }}>Lade Daten...</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not use spinners or animated components unless a Syncfusion component provides them natively (e.g., busy state on `ButtonComponent`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Locale and Language
|
||||||
|
|
||||||
|
All user-facing strings are in German.
|
||||||
|
Date formatting uses `toLocaleString('de-DE')` or `toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })`.
|
||||||
|
Never use English strings in labels, buttons, tooltips, or dialog headers visible to the end user.
|
||||||
|
|
||||||
|
**UTC time parsing**: The API returns ISO timestamps **without** a `Z` suffix (e.g., `"2025-11-27T20:03:00"`). Always append `Z` before constructing a `Date` to ensure correct UTC interpretation:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const utcStr = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z';
|
||||||
|
const date = new Date(utcStr);
|
||||||
|
```
|
||||||
|
|
||||||
|
When sending dates back to the API, use `date.toISOString()` (already UTC with `Z`).
|
||||||
18
GPU25_26_mit_Herbstferien.TXT
Normal file
18
GPU25_26_mit_Herbstferien.TXT
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"2.11.","Allerseelen",20251102,20251102,
|
||||||
|
"Ferien2","Weihnachtsferien",20251224,20260106,
|
||||||
|
"Ferien3","Semesterferien",20260216,20260222,
|
||||||
|
"Ferien4_2","Osterferien",20260328,20260406,
|
||||||
|
"Ferien4","Hl. Florian",20260504,20260504,
|
||||||
|
"26.10.","Nationalfeiertag",20251026,20251026,"F"
|
||||||
|
"27.10.","Herbstferien",20251027,20251027,"F"
|
||||||
|
"28.10.","Herbstferien",20251028,20251028,"F"
|
||||||
|
"29.10.","Herbstferien",20251029,20251029,"F"
|
||||||
|
"30.10.","Herbstferien",20251030,20251030,"F"
|
||||||
|
"31.10.","Herbstferien",20251031,20251031,"F"
|
||||||
|
"1.11.","Allerheiligen",20251101,20251101,"F"
|
||||||
|
"8.12.","Mariä Empfängnis",20251208,20251208,"F"
|
||||||
|
"1.5.","Staatsfeiertag",20260501,20260501,"F"
|
||||||
|
"14.5.","Christi Himmelfahrt",20260514,20260514,"F"
|
||||||
|
"24.5.","Pfingstsonntag",20260524,20260524,"F"
|
||||||
|
"25.5.","Pfingstmontag",20260525,20260525,"F"
|
||||||
|
"4.6.","Fronleichnam",20260604,20260604,"F"
|
||||||
425
MQTT_EVENT_PAYLOAD_GUIDE.md
Normal file
425
MQTT_EVENT_PAYLOAD_GUIDE.md
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
# MQTT Event Payload Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes the MQTT message structure used by the Infoscreen system to deliver event information from the scheduler to display clients. It covers best practices, payload formats, and versioning strategies.
|
||||||
|
|
||||||
|
## MQTT Topics
|
||||||
|
|
||||||
|
### Event Distribution
|
||||||
|
- **Topic**: `infoscreen/events/{group_id}`
|
||||||
|
- **Retained**: Yes
|
||||||
|
- **Format**: JSON array of event objects
|
||||||
|
- **Purpose**: Delivers active events to client groups
|
||||||
|
|
||||||
|
### Per-Client Configuration
|
||||||
|
- **Topic**: `infoscreen/{uuid}/group_id`
|
||||||
|
- **Retained**: Yes
|
||||||
|
- **Format**: Integer (group ID)
|
||||||
|
- **Purpose**: Assigns clients to groups
|
||||||
|
|
||||||
|
### TV Power Intent (Phase 1)
|
||||||
|
- **Topic**: `infoscreen/groups/{group_id}/power/intent`
|
||||||
|
- **QoS**: 1
|
||||||
|
- **Retained**: Yes
|
||||||
|
- **Format**: JSON object
|
||||||
|
- **Purpose**: Group-level desired power state for clients assigned to that group
|
||||||
|
|
||||||
|
Phase 1 is group-only. Per-client power intent topics and client state/ack topics are deferred to Phase 2.
|
||||||
|
|
||||||
|
Example payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"intent_id": "9cf26d9b-87a3-42f1-8446-e90bb6f6ce63",
|
||||||
|
"group_id": 12,
|
||||||
|
"desired_state": "on",
|
||||||
|
"reason": "active_event",
|
||||||
|
"issued_at": "2026-03-31T10:15:30Z",
|
||||||
|
"expires_at": "2026-03-31T10:17:00Z",
|
||||||
|
"poll_interval_sec": 30,
|
||||||
|
"active_event_ids": [148],
|
||||||
|
"event_window_start": "2026-03-31T10:15:00Z",
|
||||||
|
"event_window_end": "2026-03-31T11:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Contract notes:
|
||||||
|
- `intent_id` changes only on semantic transition (`desired_state`/`reason` changes).
|
||||||
|
- Heartbeat republishes keep `intent_id` stable while refreshing `issued_at` and `expires_at`.
|
||||||
|
- Expiry is poll-based: `max(3 x poll_interval_sec, 90)`.
|
||||||
|
|
||||||
|
### Service-Failed Notification (client → server, retained)
|
||||||
|
- **Topic**: `infoscreen/{uuid}/service_failed`
|
||||||
|
- **QoS**: 1
|
||||||
|
- **Retained**: Yes
|
||||||
|
- **Direction**: client → server
|
||||||
|
- **Purpose**: Client signals that systemd has exhausted restart attempts (`StartLimitBurst` exceeded) — manual intervention is required.
|
||||||
|
|
||||||
|
Example payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "service_failed",
|
||||||
|
"unit": "infoscreen-simclient.service",
|
||||||
|
"client_uuid": "9b8d1856-ff34-4864-a726-12de072d0f77",
|
||||||
|
"failed_at": "2026-04-05T08:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Contract notes:
|
||||||
|
- Message is retained so the server receives it even after a broker restart.
|
||||||
|
- Server persists `service_failed_at` and `service_failed_unit` to the `clients` table.
|
||||||
|
- To clear after resolution: `POST /api/clients/<uuid>/clear_service_failed` — clears the DB flag and publishes an empty retained payload to delete the retained message from the broker.
|
||||||
|
- Empty payload (empty bytes) on this topic = retain-clear in transit; listener ignores it.
|
||||||
|
|
||||||
|
### Client Command Intent (Phase 1)
|
||||||
|
- **Topic**: `infoscreen/{uuid}/commands`
|
||||||
|
- **QoS**: 1
|
||||||
|
- **Retained**: No
|
||||||
|
- **Format**: JSON object
|
||||||
|
- **Purpose**: Per-client control commands (currently `restart` and `shutdown`)
|
||||||
|
|
||||||
|
Compatibility note:
|
||||||
|
- During restart transition, server also publishes legacy restart command to `clients/{uuid}/restart` with payload `{ "action": "restart" }`.
|
||||||
|
- During topic naming transition, server also publishes command payload to `infoscreen/{uuid}/command`.
|
||||||
|
|
||||||
|
Example payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"command_id": "5d1f8b4b-7e85-44fb-8f38-3f5d5da5e2e4",
|
||||||
|
"client_uuid": "9b8d1856-ff34-4864-a726-12de072d0f77",
|
||||||
|
"action": "reboot_host",
|
||||||
|
"issued_at": "2026-04-03T12:48:10Z",
|
||||||
|
"expires_at": "2026-04-03T12:52:10Z",
|
||||||
|
"requested_by": 1,
|
||||||
|
"reason": "operator_request"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Contract notes:
|
||||||
|
- Clients must reject stale commands where local UTC time is greater than `expires_at`.
|
||||||
|
- Clients must deduplicate by `command_id` and never execute a duplicate command twice.
|
||||||
|
- `schema_version` is required for forward-compatibility.
|
||||||
|
- Allowed command action values in v1: `reboot_host`, `shutdown_host`, `restart_app`.
|
||||||
|
- `restart_app` = soft app restart (no OS reboot); `reboot_host` = full OS reboot.
|
||||||
|
- API mapping for operators: restart endpoint emits `reboot_host`; shutdown endpoint emits `shutdown_host`.
|
||||||
|
|
||||||
|
### Client Command Acknowledgements (Phase 1)
|
||||||
|
- **Topic**: `infoscreen/{uuid}/commands/ack`
|
||||||
|
- **QoS**: 1 (recommended)
|
||||||
|
- **Retained**: No
|
||||||
|
- **Format**: JSON object
|
||||||
|
- **Purpose**: Client reports command lifecycle progression back to server
|
||||||
|
|
||||||
|
Compatibility note:
|
||||||
|
- During topic naming transition, listener also accepts acknowledgements from `infoscreen/{uuid}/command/ack`.
|
||||||
|
|
||||||
|
Example payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command_id": "5d1f8b4b-7e85-44fb-8f38-3f5d5da5e2e4",
|
||||||
|
"status": "execution_started",
|
||||||
|
"error_code": null,
|
||||||
|
"error_message": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed `status` values:
|
||||||
|
- `accepted`
|
||||||
|
- `execution_started`
|
||||||
|
- `completed`
|
||||||
|
- `failed`
|
||||||
|
|
||||||
|
## Message Structure
|
||||||
|
|
||||||
|
### General Principles
|
||||||
|
|
||||||
|
1. **Type Safety**: Always include `event_type` to allow clients to parse appropriately
|
||||||
|
2. **Backward Compatibility**: Add new fields without removing old ones
|
||||||
|
3. **Extensibility**: Use nested objects for event-type-specific data
|
||||||
|
4. **UTC Timestamps**: All times in ISO 8601 format with timezone info
|
||||||
|
|
||||||
|
### Base Event Structure
|
||||||
|
|
||||||
|
Every event includes these common fields:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"title": "Event Title",
|
||||||
|
"start": "2025-10-19T09:00:00+00:00",
|
||||||
|
"end": "2025-10-19T09:30:00+00:00",
|
||||||
|
"group_id": 1,
|
||||||
|
"event_type": "presentation|website|webuntis|video|message|other",
|
||||||
|
"recurrence_rule": "FREQ=WEEKLY;BYDAY=MO,WE,FR" or null,
|
||||||
|
"recurrence_end": "2025-12-31T23:59:59+00:00" or null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Type-Specific Payloads
|
||||||
|
|
||||||
|
#### Presentation Events
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"event_type": "presentation",
|
||||||
|
"title": "Morning Announcements",
|
||||||
|
"start": "2025-10-19T09:00:00+00:00",
|
||||||
|
"end": "2025-10-19T09:30:00+00:00",
|
||||||
|
"group_id": 1,
|
||||||
|
"presentation": {
|
||||||
|
"type": "slideshow",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "slides.pdf",
|
||||||
|
"url": "http://server:8000/api/files/converted/abc123.pdf",
|
||||||
|
"checksum": null,
|
||||||
|
"size": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"slide_interval": 10000,
|
||||||
|
"auto_advance": true,
|
||||||
|
"page_progress": true,
|
||||||
|
"auto_progress": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fields**:
|
||||||
|
- `type`: Always "slideshow" for presentations
|
||||||
|
- `files`: Array of file objects with download URLs
|
||||||
|
- `slide_interval`: Milliseconds between slides (default: 5000)
|
||||||
|
- `auto_advance`: Whether to automatically advance slides
|
||||||
|
- `page_progress`: Show page number indicator
|
||||||
|
- `auto_progress`: Enable automatic progression
|
||||||
|
|
||||||
|
#### Website Events
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 124,
|
||||||
|
"event_type": "website",
|
||||||
|
"title": "School Website",
|
||||||
|
"start": "2025-10-19T09:00:00+00:00",
|
||||||
|
"end": "2025-10-19T09:30:00+00:00",
|
||||||
|
"group_id": 1,
|
||||||
|
"website": {
|
||||||
|
"type": "browser",
|
||||||
|
"url": "https://example.com/page"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fields**:
|
||||||
|
- `type`: Always "browser" for website display
|
||||||
|
- `url`: Full URL to display in embedded browser
|
||||||
|
|
||||||
|
#### WebUntis Events
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 125,
|
||||||
|
"event_type": "webuntis",
|
||||||
|
"title": "Schedule Display",
|
||||||
|
"start": "2025-10-19T09:00:00+00:00",
|
||||||
|
"end": "2025-10-19T09:30:00+00:00",
|
||||||
|
"group_id": 1,
|
||||||
|
"website": {
|
||||||
|
"type": "browser",
|
||||||
|
"url": "https://webuntis.example.com/schedule"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: WebUntis events use the same payload structure as website events. The URL is fetched from system settings (`supplement_table_url`) rather than being specified per-event. Clients treat `webuntis` and `website` event types identically—both display a website.
|
||||||
|
|
||||||
|
#### Video Events
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 126,
|
||||||
|
"event_type": "video",
|
||||||
|
"title": "Video Playback",
|
||||||
|
"start": "2025-10-19T09:00:00+00:00",
|
||||||
|
"end": "2025-10-19T09:30:00+00:00",
|
||||||
|
"group_id": 1,
|
||||||
|
"video": {
|
||||||
|
"type": "media",
|
||||||
|
"url": "http://server:8000/api/eventmedia/stream/123/video.mp4",
|
||||||
|
"autoplay": true,
|
||||||
|
"loop": false,
|
||||||
|
"volume": 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fields**:
|
||||||
|
- `type`: Always "media" for video playback
|
||||||
|
- `url`: Video streaming URL with range request support
|
||||||
|
- `autoplay`: Whether to start playing automatically (default: true)
|
||||||
|
- `loop`: Whether to loop the video (default: false)
|
||||||
|
- `volume`: Playback volume from 0.0 to 1.0 (default: 0.8)
|
||||||
|
|
||||||
|
#### Message Events (Future)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 127,
|
||||||
|
"event_type": "message",
|
||||||
|
"title": "Important Announcement",
|
||||||
|
"start": "2025-10-19T09:00:00+00:00",
|
||||||
|
"end": "2025-10-19T09:30:00+00:00",
|
||||||
|
"group_id": 1,
|
||||||
|
"message": {
|
||||||
|
"type": "html",
|
||||||
|
"content": "<h1>Important</h1><p>Message content</p>",
|
||||||
|
"style": "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Type-Based Parsing
|
||||||
|
|
||||||
|
Clients should:
|
||||||
|
1. Read the `event_type` field first
|
||||||
|
2. Switch/dispatch based on type
|
||||||
|
3. Parse type-specific nested objects (`presentation`, `website`, etc.)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Example client parsing
|
||||||
|
function parseEvent(event) {
|
||||||
|
switch (event.event_type) {
|
||||||
|
case 'presentation':
|
||||||
|
return handlePresentation(event.presentation);
|
||||||
|
case 'website':
|
||||||
|
case 'webuntis':
|
||||||
|
return handleWebsite(event.website);
|
||||||
|
case 'video':
|
||||||
|
return handleVideo(event.video);
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Graceful Degradation
|
||||||
|
|
||||||
|
- Always provide fallback values for optional fields
|
||||||
|
- Validate URLs before attempting to load
|
||||||
|
- Handle missing or malformed data gracefully
|
||||||
|
|
||||||
|
### 3. Performance Optimization
|
||||||
|
|
||||||
|
- Cache downloaded presentation files
|
||||||
|
- Use checksums to avoid re-downloading unchanged content
|
||||||
|
- Preload resources before event start time
|
||||||
|
|
||||||
|
### 4. Time Handling
|
||||||
|
|
||||||
|
- Always parse ISO 8601 timestamps with timezone awareness
|
||||||
|
- Compare event start/end times in UTC
|
||||||
|
- Account for clock drift on embedded devices
|
||||||
|
|
||||||
|
### 5. Error Recovery
|
||||||
|
|
||||||
|
- Retry failed downloads with exponential backoff
|
||||||
|
- Log errors but continue operation
|
||||||
|
- Display fallback content if event data is invalid
|
||||||
|
|
||||||
|
## Message Flow
|
||||||
|
|
||||||
|
1. **Scheduler** queries active events from database
|
||||||
|
2. **Scheduler** formats events with type-specific payloads
|
||||||
|
3. **Scheduler** publishes JSON array to `infoscreen/events/{group_id}` (retained)
|
||||||
|
4. **Client** receives retained message on connect
|
||||||
|
5. **Client** parses events and schedules display
|
||||||
|
6. **Client** downloads resources (presentations, etc.)
|
||||||
|
7. **Client** displays events at scheduled times
|
||||||
|
|
||||||
|
## Versioning Strategy
|
||||||
|
|
||||||
|
### Adding New Event Types
|
||||||
|
|
||||||
|
1. Add enum value to `EventType` in `models/models.py`
|
||||||
|
2. Update scheduler's `format_event_with_media()` in `scheduler/db_utils.py`
|
||||||
|
3. Update events API in `server/routes/events.py`
|
||||||
|
4. Add icon mapping in `get_icon_for_type()`
|
||||||
|
5. Document payload structure in this guide
|
||||||
|
|
||||||
|
### Adding Fields to Existing Types
|
||||||
|
|
||||||
|
- **Safe**: Add new optional fields to nested objects
|
||||||
|
- **Unsafe**: Remove or rename existing fields
|
||||||
|
- **Migration**: Provide both old and new field names during transition
|
||||||
|
|
||||||
|
### Example: Adding a New Field
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "presentation",
|
||||||
|
"presentation": {
|
||||||
|
"type": "slideshow",
|
||||||
|
"files": [...],
|
||||||
|
"slide_interval": 10000,
|
||||||
|
"transition_effect": "fade" // NEW FIELD (optional)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Old clients ignore unknown fields; new clients use enhanced features.
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **Hardcoding Event Types**: Use `event_type` field, not assumptions
|
||||||
|
2. **Timezone Confusion**: Always use UTC internally
|
||||||
|
3. **Missing Error Handling**: Network failures, malformed URLs, etc.
|
||||||
|
4. **Resource Leaks**: Clean up downloaded files periodically
|
||||||
|
5. **Not Handling Recurrence**: Events may repeat; check `recurrence_rule`
|
||||||
|
|
||||||
|
## System Settings Integration
|
||||||
|
|
||||||
|
Some event types rely on system-wide settings rather than per-event configuration:
|
||||||
|
|
||||||
|
### WebUntis / Supplement Table URL
|
||||||
|
- **Setting Key**: `supplement_table_url`
|
||||||
|
- **API Endpoint**: `GET/POST /api/system-settings/supplement-table`
|
||||||
|
- **Usage**: Automatically applied when creating `webuntis` events
|
||||||
|
- **Default**: Empty string (must be configured by admin)
|
||||||
|
- **Description**: This URL is shared for both Vertretungsplan (supplement table) and WebUntis displays
|
||||||
|
|
||||||
|
### Presentation Defaults
|
||||||
|
- `presentation_interval`: Default slide interval (seconds)
|
||||||
|
- `presentation_page_progress`: Show page indicators by default
|
||||||
|
- `presentation_auto_progress`: Auto-advance by default
|
||||||
|
|
||||||
|
These are applied when creating new events but can be overridden per-event.
|
||||||
|
|
||||||
|
## Testing Recommendations
|
||||||
|
|
||||||
|
1. **Unit Tests**: Validate payload serialization/deserialization
|
||||||
|
2. **Integration Tests**: Full scheduler → MQTT → client flow
|
||||||
|
3. **Edge Cases**: Empty event lists, missing URLs, malformed data
|
||||||
|
4. **Performance Tests**: Large file downloads, many events
|
||||||
|
5. **Time Tests**: Events across midnight, timezone boundaries, DST
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- `AUTH_SYSTEM.md` - Authentication and authorization
|
||||||
|
- `DATABASE_GUIDE.md` - Database schema and models
|
||||||
|
- `.github/copilot-instructions.md` - System architecture overview
|
||||||
|
- `scheduler/scheduler.py` - Event publishing implementation
|
||||||
|
- `scheduler/db_utils.py` - Event formatting logic
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
- **2025-10-19**: Initial documentation
|
||||||
|
- Documented base event structure
|
||||||
|
- Added presentation and website/webuntis payload formats
|
||||||
|
- Established best practices and versioning strategy
|
||||||
92
Makefile
Normal file
92
Makefile
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Makefile for infoscreen_2025
|
||||||
|
# Usage: run `make help` to see available targets.
|
||||||
|
|
||||||
|
# Default compose files
|
||||||
|
COMPOSE_FILES=-f docker-compose.yml -f docker-compose.override.yml
|
||||||
|
COMPOSE=docker compose $(COMPOSE_FILES)
|
||||||
|
|
||||||
|
# Registry and image names (adjust if needed)
|
||||||
|
REGISTRY=ghcr.io/robbstarkaustria
|
||||||
|
API_IMAGE=$(REGISTRY)/infoscreen-api:latest
|
||||||
|
DASH_IMAGE=$(REGISTRY)/infoscreen-dashboard:latest
|
||||||
|
LISTENER_IMAGE=$(REGISTRY)/infoscreen-listener:latest
|
||||||
|
SCHED_IMAGE=$(REGISTRY)/infoscreen-scheduler:latest
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help:
|
||||||
|
@echo "Available targets:"
|
||||||
|
@echo " up - Start dev stack (compose + override)"
|
||||||
|
@echo " down - Stop dev stack"
|
||||||
|
@echo " logs - Tail logs for all services"
|
||||||
|
@echo " logs-% - Tail logs for a specific service (e.g., make logs-server)"
|
||||||
|
@echo " build - Build all images locally"
|
||||||
|
@echo " push - Push built images to GHCR"
|
||||||
|
@echo " pull-prod - Pull prod images from GHCR"
|
||||||
|
@echo " up-prod - Start prod stack (docker-compose.prod.yml)"
|
||||||
|
@echo " down-prod - Stop prod stack"
|
||||||
|
@echo " health - Quick health checks"
|
||||||
|
@echo " fix-perms - Recursively chown workspace to current user"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Development stack ----------
|
||||||
|
.PHONY: up
|
||||||
|
up: ## Start dev stack
|
||||||
|
$(COMPOSE) up -d --build
|
||||||
|
|
||||||
|
.PHONY: down
|
||||||
|
down: ## Stop dev stack
|
||||||
|
$(COMPOSE) down
|
||||||
|
|
||||||
|
.PHONY: logs
|
||||||
|
logs: ## Tail logs for all services
|
||||||
|
$(COMPOSE) logs -f
|
||||||
|
|
||||||
|
.PHONY: logs-%
|
||||||
|
logs-%: ## Tail logs for a specific service, e.g. `make logs-server`
|
||||||
|
$(COMPOSE) logs -f $*
|
||||||
|
|
||||||
|
# ---------- Images: build/push ----------
|
||||||
|
.PHONY: build
|
||||||
|
build: ## Build all images locally
|
||||||
|
docker build -f server/Dockerfile -t $(API_IMAGE) .
|
||||||
|
docker build -f dashboard/Dockerfile -t $(DASH_IMAGE) .
|
||||||
|
docker build -f listener/Dockerfile -t $(LISTENER_IMAGE) .
|
||||||
|
docker build -f scheduler/Dockerfile -t $(SCHED_IMAGE) .
|
||||||
|
|
||||||
|
.PHONY: push
|
||||||
|
push: ## Push all images to GHCR
|
||||||
|
docker push $(API_IMAGE)
|
||||||
|
docker push $(DASH_IMAGE)
|
||||||
|
docker push $(LISTENER_IMAGE)
|
||||||
|
docker push $(SCHED_IMAGE)
|
||||||
|
|
||||||
|
# ---------- Production stack ----------
|
||||||
|
PROD_COMPOSE=docker compose -f docker-compose.prod.yml
|
||||||
|
|
||||||
|
.PHONY: pull-prod
|
||||||
|
pull-prod: ## Pull prod images
|
||||||
|
$(PROD_COMPOSE) pull
|
||||||
|
|
||||||
|
.PHONY: up-prod
|
||||||
|
up-prod: ## Start prod stack
|
||||||
|
$(PROD_COMPOSE) up -d
|
||||||
|
|
||||||
|
.PHONY: down-prod
|
||||||
|
down-prod: ## Stop prod stack
|
||||||
|
$(PROD_COMPOSE) down
|
||||||
|
|
||||||
|
# ---------- Health ----------
|
||||||
|
.PHONY: health
|
||||||
|
health: ## Quick health checks
|
||||||
|
@echo "API health:" && curl -fsS http://localhost:8000/health || true
|
||||||
|
@echo "Dashboard (dev):" && curl -fsS http://localhost:5173/ || true
|
||||||
|
@echo "MQTT TCP 1883:" && nc -z localhost 1883 && echo OK || echo FAIL
|
||||||
|
@echo "MQTT WS 9001:" && nc -z localhost 9001 && echo OK || echo FAIL
|
||||||
|
|
||||||
|
# ---------- Permissions ----------
|
||||||
|
.PHONY: fix-perms
|
||||||
|
fix-perms:
|
||||||
|
@echo "Fixing ownership to current user recursively (may prompt for sudo password)..."
|
||||||
|
sudo chown -R $$(id -u):$$(id -g) .
|
||||||
|
@echo "Done. Consider adding UID and GID to your .env to prevent future root-owned files:"
|
||||||
|
@echo " echo UID=$$(id -u) >> .env && echo GID=$$(id -g) >> .env"
|
||||||
242
README.md
Normal file
242
README.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
# Infoscreen 2025
|
||||||
|
|
||||||
|
[](https://www.docker.com/)
|
||||||
|
[](https://reactjs.org/)
|
||||||
|
[](https://flask.palletsprojects.com/)
|
||||||
|
[](https://mariadb.org/)
|
||||||
|
[](https://mosquitto.org/)
|
||||||
|
|
||||||
|
Multi-service digital signage platform for educational institutions.
|
||||||
|
|
||||||
|
Core stack:
|
||||||
|
- Dashboard: React + Vite + Syncfusion
|
||||||
|
- API: Flask + SQLAlchemy + Alembic
|
||||||
|
- DB: MariaDB
|
||||||
|
- Messaging: MQTT (Mosquitto)
|
||||||
|
- Background jobs: Redis + RQ + Gotenberg
|
||||||
|
|
||||||
|
## Latest Release Highlights (2026.1.0-alpha.16)
|
||||||
|
|
||||||
|
- Dashboard holiday status banner now updates reliably after hard refresh and after switching between settings and dashboard.
|
||||||
|
- Production startup now auto-initializes and auto-activates the academic period for the current date.
|
||||||
|
- Dashboard German UI wording was polished with proper Umlauts.
|
||||||
|
- User-facing changelog source: [dashboard/public/program-info.json](dashboard/public/program-info.json)
|
||||||
|
|
||||||
|
## Architecture (Short)
|
||||||
|
|
||||||
|
- Dashboard talks only to API (`/api/...` via Vite proxy in dev).
|
||||||
|
- API is the single writer to MariaDB.
|
||||||
|
- Listener consumes MQTT discovery/heartbeat/log/screenshot topics and updates API state.
|
||||||
|
- Scheduler expands recurring events, applies exceptions, and publishes active content to retained MQTT topics.
|
||||||
|
- Worker handles document conversions asynchronously.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Docker + Docker Compose
|
||||||
|
- Git
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
1. Clone
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/RobbStarkAustria/infoscreen_2025.git
|
||||||
|
cd infoscreen_2025
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Configure environment
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# edit values as needed
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start stack
|
||||||
|
```bash
|
||||||
|
make up
|
||||||
|
# or: docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Initialize DB (first run)
|
||||||
|
```bash
|
||||||
|
python server/initialize_database.py
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Open services
|
||||||
|
- Dashboard: http://localhost:5173
|
||||||
|
- API: http://localhost:8000
|
||||||
|
- MariaDB: localhost:3306
|
||||||
|
- MQTT: localhost:1883 (WS: 9001)
|
||||||
|
|
||||||
|
## Holiday Calendar (Quick Usage)
|
||||||
|
|
||||||
|
Settings path:
|
||||||
|
- `Settings` -> `Academic Calendar` -> `Ferienkalender: Import/Anzeige`
|
||||||
|
|
||||||
|
Workflow summary:
|
||||||
|
1. Select target academic period (archived periods are read-only/not selectable).
|
||||||
|
2. Import CSV/TXT or add/edit holidays manually.
|
||||||
|
3. Validation is period-scoped (out-of-period ranges are blocked).
|
||||||
|
4. Duplicate/overlap policy:
|
||||||
|
- exact duplicates: skipped/prevented
|
||||||
|
- same normalized `name+region` overlaps (including adjacent ranges): merged
|
||||||
|
- different-identity overlaps: conflict (manual blocked, import skipped with details)
|
||||||
|
5. Recurring events with `skip_holidays` are recalculated automatically after holiday changes.
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start/stop
|
||||||
|
make up
|
||||||
|
make down
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
make logs
|
||||||
|
make logs-server
|
||||||
|
|
||||||
|
# Health
|
||||||
|
make health
|
||||||
|
|
||||||
|
# Build/push/deploy
|
||||||
|
make build
|
||||||
|
make push
|
||||||
|
make pull-prod
|
||||||
|
make up-prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scheduler Runtime Flags
|
||||||
|
|
||||||
|
Scheduler runtime defaults can be tuned with environment variables:
|
||||||
|
|
||||||
|
- `POLL_INTERVAL_SECONDS` (default: `30`)
|
||||||
|
- `REFRESH_SECONDS` (default: `0`, disabled)
|
||||||
|
|
||||||
|
TV power coordination (server Phase 1, group-level intent only):
|
||||||
|
|
||||||
|
- `POWER_INTENT_PUBLISH_ENABLED` (default: `false`)
|
||||||
|
- `POWER_INTENT_HEARTBEAT_ENABLED` (default: `true`)
|
||||||
|
- `POWER_INTENT_EXPIRY_MULTIPLIER` (default: `3`)
|
||||||
|
- `POWER_INTENT_MIN_EXPIRY_SECONDS` (default: `90`)
|
||||||
|
|
||||||
|
Power intent topic contract for Phase 1:
|
||||||
|
|
||||||
|
- Topic: `infoscreen/groups/{group_id}/power/intent`
|
||||||
|
- QoS: `1`
|
||||||
|
- Retained: `true`
|
||||||
|
- Publish mode: transition publish + heartbeat republish each poll
|
||||||
|
- Schema version: `v1`
|
||||||
|
- Intent ID behavior: stable across unchanged heartbeat cycles; new UUID only on semantic transition (desired_state or reason change)
|
||||||
|
- Expiry rule: max(3 × poll_interval, 90 seconds)
|
||||||
|
|
||||||
|
Rollout strategy (Phase 1):
|
||||||
|
|
||||||
|
1. Keep `POWER_INTENT_PUBLISH_ENABLED=false` by default (disabled).
|
||||||
|
2. Enable in test environment first: set `POWER_INTENT_PUBLISH_ENABLED=true` on one canary group's scheduler instance.
|
||||||
|
3. Verify no unintended OFF between adjacent/overlapping events over 1–2 days.
|
||||||
|
4. Expand to 20% of production groups for 2 days (canary soak).
|
||||||
|
5. Monitor power-intent publish metrics (success rate, error rate, transition frequency) in scheduler logs.
|
||||||
|
6. Roll out to 100% once canary is stable (zero off-between-adjacent-events incidents).
|
||||||
|
7. Phase 2 (future): per-client override intents and state acknowledgments.
|
||||||
|
|
||||||
|
## Documentation Map
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
- [deployment-debian.md](deployment-debian.md)
|
||||||
|
- [deployment-ubuntu.md](deployment-ubuntu.md)
|
||||||
|
- [setup-deployment.sh](setup-deployment.sh)
|
||||||
|
|
||||||
|
### Backend & Database
|
||||||
|
- [DATABASE_GUIDE.md](DATABASE_GUIDE.md)
|
||||||
|
- [TECH-CHANGELOG.md](TECH-CHANGELOG.md)
|
||||||
|
- [server/alembic](server/alembic)
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
- [AUTH_SYSTEM.md](AUTH_SYSTEM.md)
|
||||||
|
- [AUTH_QUICKREF.md](AUTH_QUICKREF.md)
|
||||||
|
- [userrole-management.md](userrole-management.md)
|
||||||
|
- [SUPERADMIN_SETUP.md](SUPERADMIN_SETUP.md)
|
||||||
|
|
||||||
|
### Monitoring, Screenshots, Health
|
||||||
|
- [CLIENT_MONITORING_SPECIFICATION.md](CLIENT_MONITORING_SPECIFICATION.md)
|
||||||
|
- [SCREENSHOT_IMPLEMENTATION.md](SCREENSHOT_IMPLEMENTATION.md)
|
||||||
|
|
||||||
|
### MQTT & Payloads
|
||||||
|
- [MQTT_EVENT_PAYLOAD_GUIDE.md](MQTT_EVENT_PAYLOAD_GUIDE.md)
|
||||||
|
|
||||||
|
### Events, Calendar, WebUntis
|
||||||
|
- [WEBUNTIS_EVENT_IMPLEMENTATION.md](WEBUNTIS_EVENT_IMPLEMENTATION.md)
|
||||||
|
|
||||||
|
### Historical Background
|
||||||
|
- [docs/archive/ACADEMIC_PERIODS_IMPLEMENTATION_SUMMARY.md](docs/archive/ACADEMIC_PERIODS_IMPLEMENTATION_SUMMARY.md)
|
||||||
|
- [docs/archive/ACADEMIC_PERIODS_CRUD_BUILD_PLAN.md](docs/archive/ACADEMIC_PERIODS_CRUD_BUILD_PLAN.md)
|
||||||
|
- [docs/archive/PHASE_3_CLIENT_MONITORING_IMPLEMENTATION.md](docs/archive/PHASE_3_CLIENT_MONITORING_IMPLEMENTATION.md)
|
||||||
|
- [docs/archive/CLEANUP_SUMMARY.md](docs/archive/CLEANUP_SUMMARY.md)
|
||||||
|
|
||||||
|
### Conversion / Media
|
||||||
|
- [pptx_conversion_guide_gotenberg.md](pptx_conversion_guide_gotenberg.md)
|
||||||
|
|
||||||
|
### Historical / Archived Docs
|
||||||
|
- [docs/archive/CLIENT_MONITORING_IMPLEMENTATION_GUIDE.md](docs/archive/CLIENT_MONITORING_IMPLEMENTATION_GUIDE.md) - completed implementation plan/history
|
||||||
|
- [docs/archive/MQTT_DASHBOARD_V1_TO_V2_MIGRATION.md](docs/archive/MQTT_DASHBOARD_V1_TO_V2_MIGRATION.md) - completed MQTT payload migration notes
|
||||||
|
- [docs/archive/PPTX_CONVERSION_LEGACY_APPROACH.md](docs/archive/PPTX_CONVERSION_LEGACY_APPROACH.md) - legacy conversion approach (superseded)
|
||||||
|
- [docs/archive/TV_POWER_PHASE_1_COORDINATION.md](docs/archive/TV_POWER_PHASE_1_COORDINATION.md) - TV power Phase 1 coordination tasklist
|
||||||
|
- [docs/archive/TV_POWER_PHASE_1_SERVER_HANDOFF.md](docs/archive/TV_POWER_PHASE_1_SERVER_HANDOFF.md) - TV power Phase 1 server handoff notes
|
||||||
|
- [docs/archive/TV_POWER_PHASE_1_CANARY_VALIDATION.md](docs/archive/TV_POWER_PHASE_1_CANARY_VALIDATION.md) - TV power Phase 1 canary validation checklist
|
||||||
|
- [docs/archive/TV_POWER_PHASE_1_IMPLEMENTATION_CHECKLIST.md](docs/archive/TV_POWER_PHASE_1_IMPLEMENTATION_CHECKLIST.md) - TV power Phase 1 implementation checklist
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- [FRONTEND_DESIGN_RULES.md](FRONTEND_DESIGN_RULES.md)
|
||||||
|
- [dashboard/README.md](dashboard/README.md)
|
||||||
|
|
||||||
|
### Project / Contributor Guidance
|
||||||
|
- [.github/copilot-instructions.md](.github/copilot-instructions.md)
|
||||||
|
- [AI-INSTRUCTIONS-MAINTENANCE.md](AI-INSTRUCTIONS-MAINTENANCE.md)
|
||||||
|
- [DEV-CHANGELOG.md](DEV-CHANGELOG.md)
|
||||||
|
|
||||||
|
### Active Implementation Plans
|
||||||
|
- [implementation-plans/reboot-implementation-handoff-share.md](implementation-plans/reboot-implementation-handoff-share.md)
|
||||||
|
- [implementation-plans/reboot-implementation-handoff-client-team.md](implementation-plans/reboot-implementation-handoff-client-team.md)
|
||||||
|
- [implementation-plans/reboot-kickoff-summary.md](implementation-plans/reboot-kickoff-summary.md)
|
||||||
|
|
||||||
|
## API Highlights
|
||||||
|
|
||||||
|
- Core resources: clients, groups, events, academic periods
|
||||||
|
- Client command lifecycle:
|
||||||
|
- `POST /api/clients/<uuid>/restart`
|
||||||
|
- `POST /api/clients/<uuid>/shutdown`
|
||||||
|
- `GET /api/clients/commands/<command_id>`
|
||||||
|
- Holidays: `GET/POST /api/holidays`, `POST /api/holidays/upload`, `PUT/DELETE /api/holidays/<id>`
|
||||||
|
- Media: upload/download/stream + conversion status
|
||||||
|
- Auth: login/logout/change-password
|
||||||
|
- Monitoring: logs and monitoring overview endpoints
|
||||||
|
|
||||||
|
For full endpoint details, use source route files under `server/routes/` and the docs listed above.
|
||||||
|
|
||||||
|
## Project Structure (Top Level)
|
||||||
|
|
||||||
|
```text
|
||||||
|
infoscreen_2025/
|
||||||
|
├── dashboard/ # React frontend
|
||||||
|
├── server/ # Flask API + migrations + worker
|
||||||
|
├── listener/ # MQTT listener
|
||||||
|
├── scheduler/ # Event scheduler/publisher
|
||||||
|
├── models/ # Shared SQLAlchemy models
|
||||||
|
├── mosquitto/ # MQTT broker config
|
||||||
|
├── certs/ # TLS certs (prod)
|
||||||
|
└── docker-compose*.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Create branch
|
||||||
|
2. Implement change + tests
|
||||||
|
3. Update relevant docs
|
||||||
|
4. Open PR
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Match existing architecture and naming conventions
|
||||||
|
- Keep frontend aligned with [FRONTEND_DESIGN_RULES.md](FRONTEND_DESIGN_RULES.md)
|
||||||
|
- Keep service/API behavior aligned with [.github/copilot-instructions.md](.github/copilot-instructions.md)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License. See [LICENSE](LICENSE).
|
||||||
149
RESTART_VALIDATION_CHECKLIST.md
Normal file
149
RESTART_VALIDATION_CHECKLIST.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# Restart Validation Checklist
|
||||||
|
|
||||||
|
Purpose: Validate end-to-end restart command flow after MQTT auth hardening.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- API command issue route: `POST /api/clients/{uuid}/restart`
|
||||||
|
- MQTT command topic: `infoscreen/{uuid}/commands` (compat: `infoscreen/{uuid}/command`)
|
||||||
|
- MQTT ACK topic: `infoscreen/{uuid}/commands/ack` (compat: `infoscreen/{uuid}/command/ack`)
|
||||||
|
- Status API: `GET /api/clients/commands/{command_id}`
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
|
||||||
|
- Stack is up and healthy (`db`, `mqtt`, `server`, `listener`, `scheduler`).
|
||||||
|
- You have an `admin` or `superadmin` account.
|
||||||
|
- At least one canary client is online and can process restart commands.
|
||||||
|
- `.env` has valid `MQTT_USER` / `MQTT_PASSWORD`.
|
||||||
|
|
||||||
|
## 1) Open Monitoring Session (MQTT)
|
||||||
|
|
||||||
|
On host/server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -a
|
||||||
|
. ./.env
|
||||||
|
set +a
|
||||||
|
|
||||||
|
mosquitto_sub -h 127.0.0.1 -p 1883 \
|
||||||
|
-u "$MQTT_USER" -P "$MQTT_PASSWORD" \
|
||||||
|
-t "infoscreen/+/commands" \
|
||||||
|
-t "infoscreen/+/commands/ack" \
|
||||||
|
-t "infoscreen/+/command" \
|
||||||
|
-t "infoscreen/+/command/ack" \
|
||||||
|
-v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Command publish appears on `infoscreen/{uuid}/commands`.
|
||||||
|
- ACK(s) appear on `infoscreen/{uuid}/commands/ack`.
|
||||||
|
|
||||||
|
## 2) Login and Keep Session Cookie
|
||||||
|
|
||||||
|
```bash
|
||||||
|
API_BASE="http://127.0.0.1:8000"
|
||||||
|
USER="<admin_or_superadmin_username>"
|
||||||
|
PASS="<password>"
|
||||||
|
|
||||||
|
curl -sS -X POST "$API_BASE/api/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"username\":\"$USER\",\"password\":\"$PASS\"}" \
|
||||||
|
-c /tmp/infoscreen-cookies.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Login success response.
|
||||||
|
- Cookie jar file created at `/tmp/infoscreen-cookies.txt`.
|
||||||
|
|
||||||
|
## 3) Pick Target Client UUID
|
||||||
|
|
||||||
|
Option A: Use known canary UUID.
|
||||||
|
|
||||||
|
Option B: query alive clients:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS "$API_BASE/api/clients/with_alive_status" -b /tmp/infoscreen-cookies.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Choose one `uuid` where `is_alive` is `true`.
|
||||||
|
|
||||||
|
## 4) Issue Restart Command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CLIENT_UUID="<target_uuid>"
|
||||||
|
|
||||||
|
curl -sS -X POST "$API_BASE/api/clients/$CLIENT_UUID/restart" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-b /tmp/infoscreen-cookies.txt \
|
||||||
|
-d '{"reason":"canary_restart_validation"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- HTTP `202` on success.
|
||||||
|
- JSON includes `command.commandId` and initial status around `published`.
|
||||||
|
- In MQTT monitor, a command payload with:
|
||||||
|
- `schema_version: "1.0"`
|
||||||
|
- `action: "reboot_host"`
|
||||||
|
- matching `command_id`.
|
||||||
|
|
||||||
|
## 5) Poll Command Lifecycle Until Terminal
|
||||||
|
|
||||||
|
```bash
|
||||||
|
COMMAND_ID="<command_id_from_previous_step>"
|
||||||
|
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
curl -sS "$API_BASE/api/clients/commands/$COMMAND_ID" -b /tmp/infoscreen-cookies.txt
|
||||||
|
echo
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected status progression (typical):
|
||||||
|
- `queued` -> `publish_in_progress` -> `published` -> `ack_received` -> `execution_started` -> `completed`
|
||||||
|
|
||||||
|
Failure/alternate terminal states:
|
||||||
|
- `failed` (check `errorCode` / `errorMessage`)
|
||||||
|
- `blocked_safety` (reboot lockout triggered)
|
||||||
|
|
||||||
|
## 6) Validate Offline/Timeout Behavior
|
||||||
|
|
||||||
|
- Repeat step 4 for an offline client (or stop client process first).
|
||||||
|
- Confirm command does not falsely end as `completed`.
|
||||||
|
- Confirm status remains non-success and has usable failure diagnostics.
|
||||||
|
|
||||||
|
## 7) Validate Safety Lockout
|
||||||
|
|
||||||
|
Current lockout in API route:
|
||||||
|
- Threshold: 3 reboot commands
|
||||||
|
- Window: 15 minutes
|
||||||
|
|
||||||
|
Test:
|
||||||
|
- Send 4 restart commands quickly for same `uuid`.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- One request returns HTTP `429`.
|
||||||
|
- Command entry state `blocked_safety` with lockout error details.
|
||||||
|
|
||||||
|
## 8) Service Log Spot Check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs --tail=150 server listener mqtt
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- No MQTT auth errors (`Not authorized`, `Connection Refused: not authorised`).
|
||||||
|
- Listener logs show ACK processing for `command_id`.
|
||||||
|
|
||||||
|
## 9) Acceptance Criteria
|
||||||
|
|
||||||
|
- Restart command publish is visible on MQTT.
|
||||||
|
- ACK is received and mapped by listener.
|
||||||
|
- Status endpoint reaches correct terminal state.
|
||||||
|
- Safety lockout works under repeated restart attempts.
|
||||||
|
- No auth regression in broker/service logs.
|
||||||
|
|
||||||
|
## Cleanup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -f /tmp/infoscreen-cookies.txt
|
||||||
|
```
|
||||||
94
SCREENSHOT_IMPLEMENTATION.md
Normal file
94
SCREENSHOT_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Screenshot Transmission Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Clients send screenshots via MQTT during heartbeat intervals. The listener service receives these screenshots and forwards them to the server API for storage.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### MQTT Topic
|
||||||
|
- **Topic**: `infoscreen/{uuid}/screenshot`
|
||||||
|
- **Payload Format**:
|
||||||
|
- Raw binary image data (JPEG/PNG), OR
|
||||||
|
- JSON with base64-encoded image: `{"image": "<base64-string>"}`
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
#### 1. Listener Service (`listener/listener.py`)
|
||||||
|
- **Subscribes to**: `infoscreen/+/screenshot`
|
||||||
|
- **Function**: `handle_screenshot(uuid, payload)`
|
||||||
|
- Detects payload format (binary or JSON)
|
||||||
|
- Converts binary to base64 if needed
|
||||||
|
- Forwards to API via HTTP POST
|
||||||
|
|
||||||
|
#### 2. Server API (`server/routes/clients.py`)
|
||||||
|
- **Endpoint**: `POST /api/clients/<uuid>/screenshot`
|
||||||
|
- **Authentication**: No authentication required (internal service call)
|
||||||
|
- **Accepts**:
|
||||||
|
- JSON: `{"image": "<base64-encoded-image>"}`
|
||||||
|
- Binary: raw image data
|
||||||
|
- **Storage**:
|
||||||
|
- Saves to `server/screenshots/{uuid}_{timestamp}.jpg` (with timestamp)
|
||||||
|
- Saves to `server/screenshots/{uuid}.jpg` (latest, for quick retrieval)
|
||||||
|
|
||||||
|
#### 3. Retrieval (`server/wsgi.py`)
|
||||||
|
- **Endpoint**: `GET /screenshots/<uuid>`
|
||||||
|
- **Returns**: Latest screenshot for the given client UUID
|
||||||
|
- **Nginx**: Exposes `/screenshots/{uuid}.jpg` in production
|
||||||
|
|
||||||
|
## Unified Identification Method
|
||||||
|
|
||||||
|
Screenshots are identified by **client UUID**:
|
||||||
|
- Each client has a unique UUID stored in the `clients` table
|
||||||
|
- Screenshots are stored as `{uuid}.jpg` (latest) and `{uuid}_{timestamp}.jpg` (historical)
|
||||||
|
- The API endpoint requires UUID validation against the database
|
||||||
|
- Retrieval is done via `GET /screenshots/<uuid>` which returns the latest screenshot
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Client → MQTT (infoscreen/{uuid}/screenshot)
|
||||||
|
↓
|
||||||
|
Listener Service
|
||||||
|
↓ (validates client exists)
|
||||||
|
↓ (converts binary → base64 if needed)
|
||||||
|
↓
|
||||||
|
API POST /api/clients/{uuid}/screenshot
|
||||||
|
↓ (validates client UUID)
|
||||||
|
↓ (decodes base64 → binary)
|
||||||
|
↓
|
||||||
|
Filesystem: server/screenshots/{uuid}.jpg
|
||||||
|
↓
|
||||||
|
Dashboard/Nginx: GET /screenshots/{uuid}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
- **Listener**: `API_BASE_URL` (default: `http://server:8000`)
|
||||||
|
- **Server**: Screenshots stored in `server/screenshots/` directory
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
- Listener: Added `requests>=2.31.0` to `listener/requirements.txt`
|
||||||
|
- Server: Uses built-in Flask and base64 libraries
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- **Client Not Found**: Returns 404 if UUID doesn't exist in database
|
||||||
|
- **Invalid Payload**: Returns 400 if image data is missing or invalid
|
||||||
|
- **API Timeout**: Listener logs error and continues (timeout: 10s)
|
||||||
|
- **Network Errors**: Listener logs and continues operation
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- Screenshot endpoint does not require authentication (internal service-to-service)
|
||||||
|
- Client UUID must exist in database before screenshot is accepted
|
||||||
|
- Base64 encoding prevents binary data issues in JSON transport
|
||||||
|
- File size is tracked and logged for monitoring
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- Add screenshot retention policy (auto-delete old timestamped files)
|
||||||
|
- Add compression before transmission
|
||||||
|
- Add screenshot quality settings
|
||||||
|
- Add authentication between listener and API
|
||||||
|
- Add screenshot history API endpoint
|
||||||
159
SUPERADMIN_SETUP.md
Normal file
159
SUPERADMIN_SETUP.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# Superadmin User Setup
|
||||||
|
|
||||||
|
This document describes the superadmin user initialization system implemented in the infoscreen_2025 project.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The system automatically creates a default superadmin user during database initialization if one doesn't already exist. This ensures there's always an initial administrator account available for system setup and configuration.
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
1. **`server/init_defaults.py`**
|
||||||
|
- Updated to create a superadmin user with role `superadmin` (from `UserRole` enum)
|
||||||
|
- Password is securely hashed using bcrypt
|
||||||
|
- Only creates user if not already present in the database
|
||||||
|
- Provides clear feedback about creation status
|
||||||
|
|
||||||
|
2. **`.env.example`**
|
||||||
|
- Updated with new environment variables
|
||||||
|
- Includes documentation for required variables
|
||||||
|
|
||||||
|
3. **`docker-compose.yml`** and **`docker-compose.prod.yml`**
|
||||||
|
- Added environment variable passthrough for superadmin credentials
|
||||||
|
|
||||||
|
4. **`userrole-management.md`**
|
||||||
|
- Marked stage 1, step 2 as completed
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### Required
|
||||||
|
|
||||||
|
- **`DEFAULT_SUPERADMIN_PASSWORD`**: The password for the superadmin user
|
||||||
|
- **IMPORTANT**: This must be set for the superadmin user to be created
|
||||||
|
- Should be a strong, secure password
|
||||||
|
- If not set, the script will skip superadmin creation with a warning
|
||||||
|
|
||||||
|
### Optional
|
||||||
|
|
||||||
|
- **`DEFAULT_SUPERADMIN_USERNAME`**: The username for the superadmin user
|
||||||
|
- Default: `superadmin`
|
||||||
|
- Can be customized if needed
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
1. Copy `.env.example` to `.env`:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Edit `.env` and set a secure password:
|
||||||
|
```bash
|
||||||
|
DEFAULT_SUPERADMIN_USERNAME=superadmin
|
||||||
|
DEFAULT_SUPERADMIN_PASSWORD=your_secure_password_here
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run the initialization (happens automatically on container startup):
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
1. Set environment variables in your deployment configuration:
|
||||||
|
```bash
|
||||||
|
export DEFAULT_SUPERADMIN_USERNAME=superadmin
|
||||||
|
export DEFAULT_SUPERADMIN_PASSWORD=your_very_secure_password
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Deploy with docker-compose:
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
The `init_defaults.py` script runs automatically during container initialization and:
|
||||||
|
|
||||||
|
1. Checks if the username already exists in the database
|
||||||
|
2. If it exists: Prints an info message and skips creation
|
||||||
|
3. If it doesn't exist and `DEFAULT_SUPERADMIN_PASSWORD` is set:
|
||||||
|
- Hashes the password with bcrypt
|
||||||
|
- Creates the user with role `superadmin`
|
||||||
|
- Prints a success message
|
||||||
|
4. If `DEFAULT_SUPERADMIN_PASSWORD` is not set:
|
||||||
|
- Prints a warning and skips creation
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Never commit the `.env` file** to version control
|
||||||
|
2. Use a strong password (minimum 12 characters, mixed case, numbers, special characters)
|
||||||
|
3. Change the default password after first login
|
||||||
|
4. In production, consider using secrets management (Docker secrets, Kubernetes secrets, etc.)
|
||||||
|
5. Rotate passwords regularly
|
||||||
|
6. The password is hashed with bcrypt (industry standard) before storage
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
To verify the superadmin user was created:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Connect to the database container
|
||||||
|
docker exec -it infoscreen-db mysql -u root -p
|
||||||
|
|
||||||
|
# Check the users table
|
||||||
|
USE infoscreen_by_taa;
|
||||||
|
SELECT username, role, is_active FROM users WHERE role = 'superadmin';
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
```
|
||||||
|
+------------+------------+-----------+
|
||||||
|
| username | role | is_active |
|
||||||
|
+------------+------------+-----------+
|
||||||
|
| superadmin | superadmin | 1 |
|
||||||
|
+------------+------------+-----------+
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Superadmin not created
|
||||||
|
|
||||||
|
**Symptoms**: No superadmin user in database
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Check if `DEFAULT_SUPERADMIN_PASSWORD` is set in environment
|
||||||
|
2. Check container logs: `docker logs infoscreen-api`
|
||||||
|
3. Look for warning message: "⚠️ DEFAULT_SUPERADMIN_PASSWORD nicht gesetzt"
|
||||||
|
|
||||||
|
### User already exists message
|
||||||
|
|
||||||
|
**Symptoms**: Script says user already exists but you can't log in
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Verify the username is correct
|
||||||
|
2. Reset the password manually in the database
|
||||||
|
3. Or delete the user and restart containers to recreate
|
||||||
|
|
||||||
|
### Permission denied errors
|
||||||
|
|
||||||
|
**Symptoms**: Database connection errors during initialization
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Verify `DB_USER`, `DB_PASSWORD`, and `DB_NAME` environment variables
|
||||||
|
2. Check database container is healthy: `docker ps`
|
||||||
|
3. Verify database connectivity: `docker exec infoscreen-api ping -c 1 db`
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
After setting up the superadmin user:
|
||||||
|
|
||||||
|
1. Implement the `/api/me` endpoint (Stage 1, Step 3)
|
||||||
|
2. Add authentication/session management
|
||||||
|
3. Create permission decorators (Stage 1, Step 4)
|
||||||
|
4. Build user management UI (Stage 2)
|
||||||
|
|
||||||
|
See `userrole-management.md` for the complete roadmap.
|
||||||
436
TECH-CHANGELOG.md
Normal file
436
TECH-CHANGELOG.md
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
|
||||||
|
# TECH-CHANGELOG
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This changelog documents technical and developer-relevant changes included in public releases. For development workspace changes, see DEV-CHANGELOG.md. Not all changes here are reflected in the user-facing changelog (`program-info.json`), and not all UI/feature changes are repeated here. Some changes (e.g., backend refactoring, API adjustments, infrastructure, developer tooling, or internal logic) may only appear in TECH-CHANGELOG.md. For UI/feature changes, see `dashboard/public/program-info.json`.
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
- <20> **Crash detection, auto-recovery, and service_failed monitoring (2026-04-05)**:
|
||||||
|
- Added `GET /api/clients/crashed` endpoint: returns active clients with `process_status=crashed` or stale heartbeat beyond grace period, with `crash_reason` field.
|
||||||
|
- Added `restart_app` command action alongside existing `reboot_host`/`shutdown_host`; registered in `server/routes/clients.py` with same safety lockout.
|
||||||
|
- Scheduler: Added crash auto-recovery loop (feature-flagged via `CRASH_RECOVERY_ENABLED`): scans candidates via `get_crash_recovery_candidates()`, issues `reboot_host` command per client, publishes to primary + compat MQTT topics, updates command lifecycle.
|
||||||
|
- Scheduler: Added unconditional command expiry sweep each poll cycle via `sweep_expired_commands()` in `scheduler/db_utils.py`: marks non-terminal `ClientCommand` rows with `expires_at < now` as `expired`.
|
||||||
|
- Added `service_failed` topic ingestion in `listener/listener.py`: subscribe to `infoscreen/+/service_failed` on every connect; persist `service_failed_at` and `service_failed_unit` on Client; empty payload (retain clear) ignored.
|
||||||
|
- Added `broker_connection` block extraction in health payload handler: persists `mqtt_reconnect_count` and `mqtt_last_disconnect_at` from `infoscreen/{uuid}/health`.
|
||||||
|
- Added four new DB columns to `clients` table via migration `b1c2d3e4f5a6`: `service_failed_at`, `service_failed_unit`, `mqtt_reconnect_count`, `mqtt_last_disconnect_at`.
|
||||||
|
- Added `GET /api/clients/service_failed` endpoint: lists clients with `service_failed_at` set, ordered by event time desc.
|
||||||
|
- Added `POST /api/clients/<uuid>/clear_service_failed` endpoint: clears DB flag and publishes empty retained MQTT message to clear `infoscreen/{uuid}/service_failed`.
|
||||||
|
- Monitoring overview API (`GET /api/client-logs/monitoring-overview`) now includes `mqtt_reconnect_count` and `mqtt_last_disconnect_at` per client.
|
||||||
|
- Frontend: Added orange service-failed alert panel to monitoring page (hidden when empty, auto-refresh 15s, per-row Quittieren button with loading/success/error states).
|
||||||
|
- Frontend: Client detail panel in monitoring now shows MQTT reconnect count and last disconnect timestamp.
|
||||||
|
- Frontend: Added `ServiceFailedClient`, `ServiceFailedClientsResponse` types; `fetchServiceFailedClients()` and `clearServiceFailed()` API helpers in `dashboard/src/apiClients.ts`.
|
||||||
|
- Added `service_failed` topic contract to `MQTT_EVENT_PAYLOAD_GUIDE.md`.
|
||||||
|
- <20>🔐 **MQTT auth hardening for server-side services (2026-04-03)**:
|
||||||
|
- `listener/listener.py` now uses env-based broker connectivity for host/port and credentials (`MQTT_BROKER_HOST`, `MQTT_BROKER_PORT`, `MQTT_USER`, `MQTT_PASSWORD`) instead of anonymous fixed `mqtt:1883`.
|
||||||
|
- `scheduler/scheduler.py` now uses the same env-based MQTT auth path and optional TLS toggles (`MQTT_TLS_ENABLED`, `MQTT_TLS_CA_CERT`, `MQTT_TLS_CERTFILE`, `MQTT_TLS_KEYFILE`, `MQTT_TLS_INSECURE`).
|
||||||
|
- `docker-compose.yml` and `docker-compose.override.yml` now pass MQTT credentials into listener and scheduler containers for consistent authenticated connections.
|
||||||
|
- Mosquitto is now configured for authenticated access (`allow_anonymous false`, `password_file /mosquitto/config/passwd`) and bootstraps credentials from env at container startup.
|
||||||
|
- MQTT healthcheck publish now authenticates with configured broker credentials.
|
||||||
|
- 🔁 **Client command lifecycle foundation (restart/shutdown) (2026-04-03)**:
|
||||||
|
- Added persistent command tracking model `ClientCommand` in `models/models.py` and Alembic migration `aa12bb34cc56_add_client_commands_table.py`.
|
||||||
|
- Upgraded `POST /api/clients/<uuid>/restart` from fire-and-forget publish to lifecycle-aware command issuance with command metadata (`command_id`, `issued_at`, `expires_at`, `reason`, `requested_by`).
|
||||||
|
- Added `POST /api/clients/<uuid>/shutdown` endpoint with the same lifecycle contract.
|
||||||
|
- Added `GET /api/clients/commands/<command_id>` status endpoint for command-state polling.
|
||||||
|
- Added restart safety lockout in API path: max 3 restart commands per client in rolling 15 minutes, returning `blocked_safety` when threshold is exceeded.
|
||||||
|
- Added command MQTT publish to `infoscreen/{uuid}/commands` (QoS1, non-retained) and temporary legacy restart compatibility publish to `clients/{uuid}/restart`.
|
||||||
|
- Added temporary topic compatibility publish to `infoscreen/{uuid}/command` and listener acceptance of `infoscreen/{uuid}/command/ack` to bridge singular/plural naming assumptions.
|
||||||
|
- Canonicalized command payload action values to host-level semantics: `reboot_host` and `shutdown_host` (API routes remain `/restart` and `/shutdown` for operator UX compatibility).
|
||||||
|
- Added frozen payload validation snippets for integration/client tooling in `implementation-plans/reboot-command-payload-schemas.md` and `implementation-plans/reboot-command-payload-schemas.json`.
|
||||||
|
- Listener now subscribes to `infoscreen/{uuid}/commands/ack` and maps client acknowledgements into command lifecycle states (`ack_received`, `execution_started`, `completed`, `failed`).
|
||||||
|
- Initial lifecycle statuses implemented server-side: `queued`, `publish_in_progress`, `published`, `failed`, and `blocked_safety`.
|
||||||
|
- Frontend API helper extended in `dashboard/src/apiClients.ts` with `ClientCommand` typing plus command APIs for shutdown and status polling preparation.
|
||||||
|
|
||||||
|
## 2026.1.0-alpha.16 (2026-04-02)
|
||||||
|
- 🐛 **Dashboard holiday banner refactoring and state fix (`dashboard/src/dashboard.tsx`)**:
|
||||||
|
- **Motivation — unstable fetch function:** `loadHolidayStatus` had `location.pathname` in its `useCallback` dependency array, causing a new function reference to be created on every navigation event. The `useEffect` depending on that reference then re-fired, producing overlapping API calls at mount that cancelled each other via the request-sequence guard, leaving the banner unresolved.
|
||||||
|
- **Refactoring:** Removed `location.pathname` from `useCallback` deps (it was unused inside the function body). The callback now has an empty dependency array, making its reference stable across the component lifetime. The `useEffect` is keyed only to the stable callback reference — no spurious re-fires.
|
||||||
|
- **Motivation — Syncfusion stale render:** Syncfusion's `MessageComponent` caches its rendered DOM internally and does not reactively update when React passes new children or props. Even after React state changed, the component displayed whatever text was rendered on first mount.
|
||||||
|
- **Fix:** Added a `key` prop derived from `${severity}:${text}` to `MessageComponent`. React unmounts and remounts the component whenever the key changes, bypassing Syncfusion's internal caching and ensuring the correct message is always visible.
|
||||||
|
- **Result:** Active-period name and holiday overlap details now render correctly on hard refresh, initial load, and route transitions without additional API calls.
|
||||||
|
- 🗓️ **Academic period bootstrap hardening (`server/init_academic_periods.py`)**:
|
||||||
|
- Refactored initialization into idempotent flow:
|
||||||
|
- seed default periods only when table is empty,
|
||||||
|
- on every run, activate exactly the non-archived period covering `date.today()`.
|
||||||
|
- Enforces single-active behavior by deactivating all previously active periods before setting the period for today.
|
||||||
|
- Emits explicit warning if no period covers current date (all remain inactive), improving operational diagnostics.
|
||||||
|
- 🚀 **Production startup alignment (`docker-compose.prod.yml`)**:
|
||||||
|
- Server startup command now runs `python /app/server/init_academic_periods.py` after migrations and default settings bootstrap.
|
||||||
|
- Removes manual post-deploy step to set an active academic period.
|
||||||
|
- 🌐 **Dashboard UX/text refinement (`dashboard/src/dashboard.tsx`)**:
|
||||||
|
- Converted user-facing transliterated German strings to proper Umlauts in the dashboard (for example: "für", "prüfen", "Ferienüberschneidungen", "Vorfälle", "Ausfälle").
|
||||||
|
|
||||||
|
Notes for integrators:
|
||||||
|
- On production boot, the active period is now derived from current date coverage automatically.
|
||||||
|
- If customer calendars do not include today, startup logs a warning and dashboard banner will still guide admins to configure periods.
|
||||||
|
|
||||||
|
## 2026.1.0-alpha.15 (2026-03-31)
|
||||||
|
- 🔌 **TV Power Intent Phase 1 (server-side)**:
|
||||||
|
- Scheduler now publishes retained QoS1 group-level intents to `infoscreen/groups/{group_id}/power/intent`.
|
||||||
|
- Implemented deterministic intent computation helpers in `scheduler/db_utils.py` (`compute_group_power_intent_basis`, `build_group_power_intent_body`, `compute_group_power_intent_fingerprint`).
|
||||||
|
- Implemented transition + heartbeat semantics in `scheduler/scheduler.py`: stable `intent_id` on heartbeat; new `intent_id` only on semantic transition.
|
||||||
|
- Added startup publish and MQTT reconnect republish behavior for retained intent continuity.
|
||||||
|
- Added poll-based expiry rule: `expires_at = issued_at + max(3 × poll_interval_sec, 90s)`.
|
||||||
|
- Added scheduler tests and canary validation artifacts: `scheduler/test_power_intent_utils.py`, `scheduler/test_power_intent_scheduler.py`, `test_power_intent_canary.py`, and `TV_POWER_CANARY_VALIDATION_CHECKLIST.md`.
|
||||||
|
- 🗃️ **Holiday data model scoping to academic periods**:
|
||||||
|
- Added period scoping for holidays via `SchoolHoliday.academic_period_id` (FK to academic periods) in `models/models.py`.
|
||||||
|
- Added Alembic migration `f3c4d5e6a7b8_scope_school_holidays_to_academic_.py` to introduce FK/index/constraint updates for period-aware holiday storage.
|
||||||
|
- Updated uniqueness semantics and indexing so holiday identity is evaluated in the selected academic period context.
|
||||||
|
- 🔌 **Holiday API hardening (`server/routes/holidays.py`)**:
|
||||||
|
- Extended to period-scoped workflows for list/import/manual CRUD.
|
||||||
|
- Added manual CRUD endpoints and behavior:
|
||||||
|
- `POST /api/holidays`
|
||||||
|
- `PUT /api/holidays/<id>`
|
||||||
|
- `DELETE /api/holidays/<id>`
|
||||||
|
- Enforced date-range validation against selected academic period for both import and manual writes.
|
||||||
|
- Added duplicate prevention (normalized name/region matching with null-safe handling).
|
||||||
|
- Implemented overlap policy:
|
||||||
|
- Same normalized `name+region` overlaps (including adjacent ranges) are merged.
|
||||||
|
- Different-identity overlaps are treated as conflicts (manual blocked, import skipped with details).
|
||||||
|
- Import responses now include richer counters/details (inserted/updated/merged/skipped/conflicts).
|
||||||
|
- 🔁 **Recurrence integration updates**:
|
||||||
|
- Event holiday-skip exception regeneration now resolves holidays by `academic_period_id` instead of global holiday sets.
|
||||||
|
- Updated event-side recurrence handling (`server/routes/events.py`) to keep EXDATE behavior in sync with period-scoped holidays.
|
||||||
|
- 🖥️ **Frontend integration (technical)**:
|
||||||
|
- Updated holiday API client (`dashboard/src/apiHolidays.ts`) for period-aware list/upload and manual CRUD operations.
|
||||||
|
- Settings holiday management (`dashboard/src/settings.tsx`) now binds import/list/manual CRUD to selected academic period and surfaces conflict/merge outcomes.
|
||||||
|
- Dashboard and appointments holiday data loading updated to active-period context.
|
||||||
|
- 📖 **Documentation & release alignment**:
|
||||||
|
- Updated `.github/copilot-instructions.md` with period-scoped holiday conventions, overlap policy, and settings behavior.
|
||||||
|
- Refactored root `README.md` to index-style documentation and archived historical implementation docs under `docs/archive/`.
|
||||||
|
- Synchronized release line with user-facing version `2026.1.0-alpha.15` in `dashboard/public/program-info.json`.
|
||||||
|
|
||||||
|
Notes for integrators:
|
||||||
|
- Holiday operations now require a clear academic period context; archived periods should be treated as read-only for holiday mutation flows.
|
||||||
|
- Existing recurrence flows depend on period-scoped holiday sets; verify period assignment for recurring master events when validating skip-holidays behavior.
|
||||||
|
|
||||||
|
## 2026.1.0-alpha.14 (2026-01-28)
|
||||||
|
- 🗓️ **Ressourcen Page (Timeline View)**:
|
||||||
|
- New frontend page: `dashboard/src/ressourcen.tsx` (357 lines) – Parallel timeline view showing active events for all room groups
|
||||||
|
- Uses Syncfusion ScheduleComponent with TimelineViews module for resource-based scheduling
|
||||||
|
- Compact visualization: 65px row height per group, dynamically calculated total container height
|
||||||
|
- Real-time event loading: Fetches events per group for current date range on mount and view/date changes
|
||||||
|
- Timeline modes: Day (default) and Week views with date range calculation
|
||||||
|
- Color-coded event bars: Uses `getGroupColor()` from `groupColors.ts` for group theme matching
|
||||||
|
- Displays first active event per group with type, title, and time window
|
||||||
|
- Filters out "Nicht zugeordnet" group from timeline display
|
||||||
|
- Resource mapping: Each group becomes a timeline resource row, events mapped via `ResourceId`
|
||||||
|
- Syncfusion modules: TimelineViews, Resize, DragAndDrop injected for rich interaction
|
||||||
|
- 🎨 **Ressourcen Styling**:
|
||||||
|
- New CSS file: `dashboard/src/ressourcen.css` (178 lines) with modern Material 3 design
|
||||||
|
- Fixed CSS lint errors: Converted `rgba()` to modern `rgb()` notation with percentage alpha values (`rgb(0 0 0 / 10%)`)
|
||||||
|
- Removed unnecessary quotes from font-family names (Roboto, Oxygen, Ubuntu, Cantarell)
|
||||||
|
- Fixed CSS selector specificity ordering (`.e-schedule` before `.ressourcen-timeline-wrapper .e-schedule`)
|
||||||
|
- Card-based controls layout with shadow and rounded corners
|
||||||
|
- Group ordering panel with scrollable list and action buttons
|
||||||
|
- Responsive timeline wrapper with flex layout
|
||||||
|
- 🔌 **Group Order API**:
|
||||||
|
- New backend endpoints in `server/routes/groups.py`:
|
||||||
|
- `GET /api/groups/order` – Retrieve saved group display order (returns JSON with `order` array of group IDs)
|
||||||
|
- `POST /api/groups/order` – Persist group display order (accepts JSON with `order` array)
|
||||||
|
- Order persistence: Stored in `system_settings` table with key `group_display_order` (JSON array of integers)
|
||||||
|
- Automatic synchronization: Missing group IDs added to order, removed IDs filtered out
|
||||||
|
- Frontend integration: Group order panel with drag up/down buttons, real-time reordering with backend sync
|
||||||
|
- 🖥️ **Frontend Technical**:
|
||||||
|
- State management: React hooks with unused setters removed (setTimelineView, setViewDate) to resolve lint warnings
|
||||||
|
- TypeScript: Changed `let` to `const` for immutable end date calculation
|
||||||
|
- UTC date parsing: Uses parseUTCDate callback to append 'Z' and ensure UTC interpretation
|
||||||
|
- Event formatting: Capitalizes first letter of event type for display (e.g., "Website - Title")
|
||||||
|
- Loading state: Shows loading indicator while fetching group/event data
|
||||||
|
- Schedule height: Dynamic calculation based on `groups.length * 65px + 100px` for header
|
||||||
|
- 📖 **Documentation**:
|
||||||
|
- Updated `.github/copilot-instructions.md`:
|
||||||
|
- Added Ressourcen page to "Recent changes" section (January 2026)
|
||||||
|
- Added `ressourcen.tsx` and `ressourcen.css` to "Important files" list
|
||||||
|
- Added Groups API order endpoints documentation
|
||||||
|
- Added comprehensive Ressourcen page section to "Frontend patterns"
|
||||||
|
- Updated `README.md`:
|
||||||
|
- Added Ressourcen page to "Pages Overview" section with feature details
|
||||||
|
- Added `GET/POST /api/groups/order` to Core Resources API section
|
||||||
|
- Bumped version in `dashboard/public/program-info.json` to `2026.1.0-alpha.14` with user-facing changelog
|
||||||
|
|
||||||
|
Notes for integrators:
|
||||||
|
- Group order API returns JSON with `{ "order": [1, 2, 3, ...] }` structure (array of group IDs)
|
||||||
|
- Timeline view automatically filters "Nicht zugeordnet" group for cleaner display
|
||||||
|
- CSS follows modern Material 3 color-function notation (`rgb(r g b / alpha%)`)
|
||||||
|
- Syncfusion ScheduleComponent requires TimelineViews, Resize, and DragAndDrop modules injected
|
||||||
|
|
||||||
|
Backend technical work (post-release notes; no version bump):
|
||||||
|
- 📊 **Client Monitoring Infrastructure (Server-Side) (2026-03-10)**:
|
||||||
|
- Database schema: New Alembic migration `c1d2e3f4g5h6_add_client_monitoring.py` (idempotent) adds:
|
||||||
|
- `client_logs` table: Stores centralized logs with columns (id, client_uuid, timestamp, level, message, context, created_at)
|
||||||
|
- Foreign key: `client_logs.client_uuid` → `clients.uuid` (ON DELETE CASCADE)
|
||||||
|
- Health monitoring columns added to `clients` table: `current_event_id`, `current_process`, `process_status`, `process_pid`, `last_screenshot_analyzed`, `screen_health_status`, `last_screenshot_hash`
|
||||||
|
- Indexes for performance: (client_uuid, timestamp DESC), (level, timestamp DESC), (created_at DESC)
|
||||||
|
- Data models (`models/models.py`):
|
||||||
|
- New enums: `LogLevel` (ERROR, WARN, INFO, DEBUG), `ProcessStatus` (running, crashed, starting, stopped), `ScreenHealthStatus` (OK, BLACK, FROZEN, UNKNOWN)
|
||||||
|
- New model: `ClientLog` with foreign key to `Client` (CASCADE on delete)
|
||||||
|
- Extended `Client` model with 7 health monitoring fields
|
||||||
|
- MQTT listener extensions (`listener/listener.py`):
|
||||||
|
- New topic subscriptions: `infoscreen/+/logs/error`, `infoscreen/+/logs/warn`, `infoscreen/+/logs/info`, `infoscreen/+/health`
|
||||||
|
- Log handler: Parses JSON payloads, creates `ClientLog` entries, validates client UUID exists (FK constraint)
|
||||||
|
- Health handler: Updates client state from MQTT health messages
|
||||||
|
- Enhanced heartbeat handler: Captures `process_status`, `current_process`, `process_pid`, `current_event_id` from payload
|
||||||
|
- API endpoints (`server/routes/client_logs.py`):
|
||||||
|
- `GET /api/client-logs/<uuid>/logs` – Retrieve client logs with filters (level, limit, since); authenticated (admin_or_higher)
|
||||||
|
- `GET /api/client-logs/summary` – Get log counts by level per client for last 24h; authenticated (admin_or_higher)
|
||||||
|
- `GET /api/client-logs/monitoring-overview` – Aggregated monitoring overview for dashboard clients/statuses; authenticated (admin_or_higher)
|
||||||
|
- `GET /api/client-logs/recent-errors` – System-wide error monitoring; authenticated (admin_or_higher)
|
||||||
|
- `GET /api/client-logs/test` – Infrastructure validation endpoint (no auth required)
|
||||||
|
- Blueprint registered in `server/wsgi.py` as `client_logs_bp`
|
||||||
|
- Dev environment fix: Updated `docker-compose.override.yml` listener service to use `working_dir: /workspace` and direct command path for live code reload
|
||||||
|
- 🖥️ **Monitoring Dashboard Integration (2026-03-24)**:
|
||||||
|
- Frontend monitoring dashboard (`dashboard/src/monitoring.tsx`) is active and wired to monitoring APIs
|
||||||
|
- Superadmin-only route/menu integration completed in `dashboard/src/App.tsx`
|
||||||
|
- Added dashboard monitoring API client (`dashboard/src/apiClientMonitoring.ts`) for overview and recent errors
|
||||||
|
- 🐛 **Presentation Flags Persistence Fix (2026-03-24)**:
|
||||||
|
- Fixed persistence for presentation flags `page_progress` and `auto_progress` across create/update and detached-occurrence flows
|
||||||
|
- API serialization now reliably returns stored values for presentation behavior fields
|
||||||
|
- 📡 **MQTT Protocol Extensions**:
|
||||||
|
- New log topics: `infoscreen/{uuid}/logs/{error|warn|info}` with JSON payload (timestamp, message, context)
|
||||||
|
- New health topic: `infoscreen/{uuid}/health` with metrics (expected_state, actual_state, health_metrics)
|
||||||
|
- Enhanced heartbeat: `infoscreen/{uuid}/heartbeat` now includes `current_process`, `process_pid`, `process_status`, `current_event_id`
|
||||||
|
- QoS levels: ERROR/WARN logs use QoS 1 (at least once), INFO/health use QoS 0 (fire and forget)
|
||||||
|
- 📖 **Documentation**:
|
||||||
|
- New file: `CLIENT_MONITORING_SPECIFICATION.md` – Comprehensive 20-section technical spec for client-side implementation (MQTT protocol, process monitoring, auto-recovery, payload formats, testing guide)
|
||||||
|
- New file: `CLIENT_MONITORING_IMPLEMENTATION_GUIDE.md` – 5-phase implementation guide (database, backend, client watchdog, dashboard UI, testing)
|
||||||
|
- Updated `.github/copilot-instructions.md`: Added MQTT topics section, client monitoring integration notes
|
||||||
|
- ✅ **Validation**:
|
||||||
|
- End-to-end testing completed: MQTT message → listener → database → API confirmed working
|
||||||
|
- Test flow: Published message to `infoscreen/{real-uuid}/logs/error` → listener logs showed receipt → database stored entry → test API returned log data
|
||||||
|
- Known client UUIDs validated: 9b8d1856-ff34-4864-a726-12de072d0f77, 7f65c615-5827-4ada-9ac8-4727c2e8ee55, bdbfff95-0b2b-4265-8cc7-b0284509540a
|
||||||
|
|
||||||
|
Notes for integrators:
|
||||||
|
- Tiered logging strategy: ERROR/WARN always centralized (QoS 1), INFO dev-only (QoS 0), DEBUG local-only
|
||||||
|
- Monitoring dashboard is implemented and consumes `/api/client-logs/monitoring-overview`, `/api/client-logs/recent-errors`, and `/api/client-logs/<uuid>/logs`
|
||||||
|
- Foreign key constraint prevents logging for non-existent clients (data integrity enforced)
|
||||||
|
- Migration is idempotent and can be safely rerun after interruption
|
||||||
|
- Use `GET /api/client-logs/test` for quick infrastructure validation without authentication
|
||||||
|
|
||||||
|
## 2025.1.0-beta.1 (TBD)
|
||||||
|
- 🔐 **User Management & Role-Based Access Control**:
|
||||||
|
- Backend: Implemented comprehensive user management API (`server/routes/users.py`) with 6 endpoints (GET, POST, PUT, DELETE users + password reset).
|
||||||
|
- Data model: Extended `User` with 7 audit/security fields via Alembic migration (`4f0b8a3e5c20_add_user_audit_fields.py`):
|
||||||
|
- `last_login_at`, `last_password_change_at`: TIMESTAMP (UTC) for auth event tracking
|
||||||
|
- `failed_login_attempts`, `last_failed_login_at`: Security monitoring for brute-force detection
|
||||||
|
- `locked_until`: TIMESTAMP placeholder for account lockout (infrastructure in place, not yet enforced)
|
||||||
|
- `deactivated_at`, `deactivated_by`: Soft-delete audit trail (FK self-reference)
|
||||||
|
- Role hierarchy: 4-tier privilege escalation (user → editor → admin → superadmin) enforced at API and UI levels:
|
||||||
|
- Admin cannot see, create, or manage superadmin accounts
|
||||||
|
- Admin can manage user/editor/admin roles only
|
||||||
|
- Superadmin can manage all roles including other superadmins
|
||||||
|
- Auth routes enhanced (`server/routes/auth.py`):
|
||||||
|
- Login: Sets `last_login_at`, resets `failed_login_attempts` on success; increments `failed_login_attempts` and `last_failed_login_at` on failure
|
||||||
|
- Password change: Sets `last_password_change_at` on both self-service and admin reset
|
||||||
|
- New endpoint: `PUT /api/auth/change-password` for self-service password change (all authenticated users; requires current password verification)
|
||||||
|
- User API security:
|
||||||
|
- Admin cannot reset superadmin passwords
|
||||||
|
- Self-account protections: cannot change own role/status, cannot delete self
|
||||||
|
- Admin cannot use password reset endpoint for their own account (backend check enforces self-service requirement)
|
||||||
|
- All user responses include audit fields in camelCase (lastLoginAt, lastPasswordChangeAt, failedLoginAttempts, deactivatedAt, deactivatedBy)
|
||||||
|
- Soft-delete pattern: Deactivation by default (sets `deactivated_at` and `deactivated_by`); hard-delete superadmin-only
|
||||||
|
- 🖥️ **Frontend User Management**:
|
||||||
|
- New page: `dashboard/src/users.tsx` – Full CRUD interface (820 lines) with Syncfusion components
|
||||||
|
- GridComponent: 20 per page (configurable), sortable columns (ID, username, role), custom action button template with role-based visibility
|
||||||
|
- Statistics cards: Total users, active (non-deactivated), inactive (deactivated) counts
|
||||||
|
- Dialogs: Create (username/password/role/status), Edit (with self-edit protections), Password Reset (admin only, no current password required), Delete (superadmin only, self-check), Details (read-only audit info with formatted timestamps)
|
||||||
|
- Role badges: Color-coded display (user: gray, editor: blue, admin: green, superadmin: red)
|
||||||
|
- Audit information display: last login, password change, last failed login, deactivation timestamps and deactivating user
|
||||||
|
- Self-protection: Delete button hidden for current user (prevents accidental self-deletion)
|
||||||
|
- Menu visibility: "Benutzer" sidebar item only visible to admin+ (role-gated in App.tsx)
|
||||||
|
- 💬 **Header User Menu**:
|
||||||
|
- Enhanced top-right dropdown with "Passwort ändern" (lock icon), "Profil", and "Abmelden"
|
||||||
|
- Self-service password change dialog: Available to all authenticated users; requires current password verification, new password min 6 chars, must match confirm field
|
||||||
|
- Implemented with Syncfusion DropDownButton (`@syncfusion/ej2-react-splitbuttons`)
|
||||||
|
- 🔌 **API Client**:
|
||||||
|
- New file: `dashboard/src/apiUsers.ts` – Type-safe TypeScript client (143 lines) for user operations
|
||||||
|
- Functions: listUsers(), getUser(), createUser(), updateUser(), resetUserPassword(), deleteUser()
|
||||||
|
- All functions include proper error handling and camelCase JSON mapping
|
||||||
|
- 📖 **Documentation**:
|
||||||
|
- Updated `.github/copilot-instructions.md`: Added comprehensive sections on user model audit fields, user management API routes, auth routes, header menu, and user management page implementation
|
||||||
|
- Updated `README.md`: Added user management to Key Features, API endpoints (User Management + Authentication sections), Pages Overview, and Security & Authentication sections with RBAC details
|
||||||
|
- Updated `TECH-CHANGELOG.md`: Documented all technical changes and integration notes
|
||||||
|
|
||||||
|
Notes for integrators:
|
||||||
|
- User CRUD endpoints accept/return all audit fields in camelCase
|
||||||
|
- Admin password reset (`PUT /api/users/<id>/password`) cannot be used for admin's own account; users must use self-service endpoint
|
||||||
|
- Frontend enforces role-gated menu visibility; backend validates all role transitions to prevent privilege escalation
|
||||||
|
- Soft-delete is default; hard-delete (superadmin-only) requires explicit confirmation
|
||||||
|
- Audit fields populated automatically on login/logout/password-change/deactivation events
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Backend rework (post-release notes; no version bump):
|
||||||
|
- 🧩 Dev Container hygiene: Remote Containers runs on UI (`remote.extensionKind`), removed in-container install to prevent reappearance loops; switched `postCreateCommand` to `npm ci` for reproducible dashboard installs; `postStartCommand` aliases made idempotent.
|
||||||
|
- 🔄 Serialization: Consolidated snake_case→camelCase via `server/serializers.py` for all JSON outputs; ensured enums/UTC datetimes serialize consistently across routes.
|
||||||
|
- 🕒 Time handling: Normalized naive timestamps to UTC in all back-end comparisons (events, scheduler, groups) and kept ISO strings without `Z` in API responses; frontend appends `Z`.
|
||||||
|
- 📡 Streaming: Stabilized range-capable endpoint (`/api/eventmedia/stream/<media_id>/<filename>`), clarified client handling; scheduler emits basic HEAD-probe metadata (`mime_type`, `size`, `accept_ranges`).
|
||||||
|
- 📅 Recurrence/exceptions: Ensured EXDATE tokens (RFC 5545 UTC) align with occurrence start; detached-occurrence flow confirmed via `POST /api/events/<id>/occurrences/<date>/detach`.
|
||||||
|
- 🧰 Routes cleanup: Applied `dict_to_camel_case()` before `jsonify()` uniformly; verified Session lifecycle consistency (open/commit/close) across blueprints.
|
||||||
|
- 🔄 **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
|
||||||
|
- Dev container: prefer `npm ci` and UI-only Remote Containers to avoid extension drift in-container.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Component build metadata template (for traceability)
|
||||||
|
Record component builds under the unified app version when releasing:
|
||||||
|
|
||||||
|
```
|
||||||
|
Component builds for this release
|
||||||
|
- API: image tag `ghcr.io/robbstarkaustria/api:<short-sha>` (commit `<sha>`)
|
||||||
|
- Dashboard: image tag `ghcr.io/robbstarkaustria/dashboard:<short-sha>` (commit `<sha>`)
|
||||||
|
- Scheduler: image tag `ghcr.io/robbstarkaustria/scheduler:<short-sha>` (commit `<sha>`)
|
||||||
|
- Listener: image tag `ghcr.io/robbstarkaustria/listener:<short-sha>` (commit `<sha>`)
|
||||||
|
- Worker: image tag `ghcr.io/robbstarkaustria/worker:<short-sha>` (commit `<sha>`)
|
||||||
|
```
|
||||||
|
|
||||||
|
This is informational (build metadata) and does not change the user-facing version number.
|
||||||
|
|
||||||
|
## 2025.1.0-alpha.11 (2025-11-05)
|
||||||
|
- 🗃️ Data model & API:
|
||||||
|
- Added `muted` (Boolean) to `Event` with Alembic migration; create/update and GET endpoints now accept, persist, and return `muted` alongside `autoplay`, `loop`, and `volume` for video events.
|
||||||
|
- Video event fields consolidated: `event_media_id`, `autoplay`, `loop`, `volume`, `muted`.
|
||||||
|
- 🔗 Streaming:
|
||||||
|
- Added range-capable streaming endpoint: `GET /api/eventmedia/stream/<media_id>/<filename>` (supports byte-range requests 206 for seeking).
|
||||||
|
- Scheduler: Performs a best-effort HEAD probe for video stream URLs and includes basic metadata in the emitted payload (`mime_type`, `size`, `accept_ranges`). Placeholders added for `duration`, `resolution`, `bitrate`, `qualities`, `thumbnails`, `checksum`.
|
||||||
|
- 🖥️ Frontend/Dashboard:
|
||||||
|
- Settings page refactored to nested tabs with controlled tab selection (`selectedItem`) to prevent sub-tab jumps.
|
||||||
|
- Settings → Events → Videos: Added system-wide defaults with load/save via system settings keys: `video_autoplay`, `video_loop`, `video_volume`, `video_muted`.
|
||||||
|
- Event modal (CustomEventModal): Exposes per-event video options including “Ton aus” (`muted`) and initializes all video fields from system defaults when creating new events.
|
||||||
|
- Academic Calendar (Settings): Merged “Schulferien Import” and “Liste” into a single sub-tab “📥 Import & Liste”.
|
||||||
|
- 📖 Documentation:
|
||||||
|
- Updated `README.md` and `.github/copilot-instructions.md` for video payload (incl. `muted`), streaming endpoint (206), nested Settings tabs, and video defaults keys; clarified client handling of `video` payloads.
|
||||||
|
- Updated `dashboard/public/program-info.json` (user-facing changelog) and bumped version to `2025.1.0-alpha.11` with corresponding UI/UX notes.
|
||||||
|
|
||||||
|
Notes for integrators:
|
||||||
|
- Clients should parse `event_type` and handle the nested `video` payload, honoring `autoplay`, `loop`, `volume`, and `muted`. Use the streaming endpoint with HTTP Range for seeking.
|
||||||
|
- System settings keys for video defaults: `video_autoplay`, `video_loop`, `video_volume`, `video_muted`.
|
||||||
|
|
||||||
|
## 2025.1.0-alpha.10 (2025-10-25)
|
||||||
|
- No new developer-facing changes in this release.
|
||||||
|
- UI/UX updates are documented in `dashboard/public/program-info.json`:
|
||||||
|
- Event modal: Surfaced video options (Autoplay, Loop, Volume).
|
||||||
|
- FileManager: Increased upload limits (Full-HD); client-side duration validation (max 10 minutes).
|
||||||
|
|
||||||
|
## 2025.1.0-alpha.9 (2025-10-19)
|
||||||
|
- 🗓️ Events/API:
|
||||||
|
- Implemented new `webuntis` event type. Event creation now resolves the URL from the system setting `supplement_table_url`; returns 400 if unset.
|
||||||
|
- Removed obsolete `webuntis-url` settings endpoints. Use `GET/POST /api/system-settings/supplement-table` for URL and enabled state (shared for WebUntis/Vertretungsplan).
|
||||||
|
- Initialization defaults: dropped `webuntis_url`; updated `supplement_table_url` description to “Vertretungsplan / WebUntis”.
|
||||||
|
- 🚦 Scheduler payloads:
|
||||||
|
- Unified Website/WebUntis payload: both emit a nested `website` object `{ "type": "browser", "url": "…" }`; `event_type` remains either `website` or `webuntis` for dispatch.
|
||||||
|
- Payloads now include a top-level `event_type` string for all events to aid client dispatch.
|
||||||
|
- 🖥️ Frontend/Dashboard:
|
||||||
|
- Program info updated to `2025.1.0-alpha.13` with release notes.
|
||||||
|
- Settings → Events: WebUntis now uses the existing Supplement-Table URL; no separate WebUntis URL field.
|
||||||
|
- Event modal: WebUntis type behaves like Website (no per-event URL input).
|
||||||
|
- 📖 Documentation:
|
||||||
|
- Added `MQTT_EVENT_PAYLOAD_GUIDE.md` (message structure, client best practices, versioning).
|
||||||
|
- Added `WEBUNTIS_EVENT_IMPLEMENTATION.md` (design notes, admin setup, testing checklist).
|
||||||
|
- Updated `.github/copilot-instructions.md` and `README.md` for the unified Website/WebUntis handling and settings usage.
|
||||||
|
|
||||||
|
Notes for integrators:
|
||||||
|
- If you previously integrated against `/api/system-settings/webuntis-url`, migrate to `/api/system-settings/supplement-table`.
|
||||||
|
- 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.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`.
|
||||||
|
- 🖥️ Dashboard: Settings → Events tab now includes Presentations defaults (interval, page-progress, auto-progress) with load/save via API; event modal applies defaults on create and persists per-event values on edit.
|
||||||
|
- 📖 Docs: Updated README and Copilot instructions for new scheduler behavior, UTC handling, presentation defaults, and per-event flags.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2025.1.0-alpha.11 (2025-10-16)
|
||||||
|
- ✨ Settings page: New tab layout (Syncfusion) with role-based visibility – Tabs: 📅 Academic Calendar, 🖥️ Display & Clients, 🎬 Media & Files, 🗓️ Events, ⚙️ System.
|
||||||
|
- 🛠️ Settings (Technical): API calls now use relative /api paths via the Vite proxy (prevents CORS and double /api).
|
||||||
|
- 📖 Docs: README updated for settings page (tabs) and system settings API.
|
||||||
|
|
||||||
|
## 2025.1.0-alpha.10 (2025-10-15)
|
||||||
|
- 🔐 Auth: Login and user management implemented (role-based, persistent sessions).
|
||||||
|
- 🧩 Frontend: Syncfusion SplitButtons integrated (react-splitbuttons) and Vite config updated for pre-bundling.
|
||||||
|
- 🐛 Fix: Import error ‘@syncfusion/ej2-react-splitbuttons’ – instructions added to README (optimizeDeps + volume reset).
|
||||||
|
|
||||||
|
## 2025.1.0-alpha.9 (2025-10-14)
|
||||||
|
- ✨ UI: Unified deletion workflow for appointments – all types (single, single instance, entire series) handled with custom dialogs.
|
||||||
|
- 🔧 Frontend: Syncfusion RecurrenceAlert and DeleteAlert intercepted and replaced with custom dialogs (including final confirmation for series deletion).
|
||||||
|
- 📖 Docs: README and Copilot instructions expanded for deletion workflow and dialog handling.
|
||||||
|
|
||||||
|
## 2025.1.0-alpha.8 (2025-10-11)
|
||||||
|
- 🎨 Theme: Migrated to Syncfusion Material 3; centralized CSS imports in main.tsx
|
||||||
|
- 🧹 Cleanup: Tailwind CSS completely removed (packages, PostCSS, Stylelint, config files)
|
||||||
|
- 🧩 Group management: "infoscreen_groups" migrated to Syncfusion components (Buttons, Dialogs, DropDownList, TextBox); improved spacing
|
||||||
|
- 🔔 Notifications: Unified toast/dialog wording; last alert usage replaced
|
||||||
|
- 📖 Docs: README and Copilot instructions updated (Material 3, centralized styles, no Tailwind)
|
||||||
|
|
||||||
|
## 2025.1.0-alpha.7 (2025-09-21)
|
||||||
|
- 🧭 UI: Period selection (Syncfusion) next to group selection; compact layout
|
||||||
|
- ✅ Display: Badge for existing holiday plan + counter ‘Holidays in view’
|
||||||
|
- 🛠️ API: Endpoints for academic periods (list, active GET/POST, for_date)
|
||||||
|
- 📅 Scheduler: By default, no scheduling during holidays; block display like all-day event; black text color
|
||||||
|
- 📤 Holidays: Upload from TXT/CSV (headless TXT uses columns 2–4)
|
||||||
|
- 🔧 UX: Switches in a row; dropdown widths optimized
|
||||||
|
|
||||||
|
## 2025.1.0-alpha.6 (2025-09-20)
|
||||||
|
- 🗓️ NEW: Academic periods system – support for school years, semesters, trimesters
|
||||||
|
- 🏗️ DATABASE: New 'academic_periods' table for time-based organization
|
||||||
|
- 🔗 EXTENDED: Events and media can now optionally be linked to an academic period
|
||||||
|
- 📊 ARCHITECTURE: Fully backward-compatible implementation for gradual rollout
|
||||||
|
- ⚙️ TOOLS: Automatic creation of standard school years for Austrian schools
|
||||||
|
|
||||||
|
## 2025.1.0-alpha.5 (2025-09-14)
|
||||||
|
- Backend: Complete redesign of backend handling for group assignments of new clients and steps for changing group assignment.
|
||||||
|
|
||||||
|
## 2025.1.0-alpha.4 (2025-09-01)
|
||||||
|
- Deployment: Base structure for deployment tested and optimized.
|
||||||
|
- FIX: Program error when switching view on media page fixed.
|
||||||
|
|
||||||
|
## 2025.1.0-alpha.3 (2025-08-30)
|
||||||
|
- NEW: Program info page with dynamic data, build info, and changelog.
|
||||||
|
- NEW: Logout functionality implemented.
|
||||||
|
- FIX: Sidebar width corrected in collapsed state.
|
||||||
|
|
||||||
|
## 2025.1.0-alpha.2 (2025-08-29)
|
||||||
|
- INFO: Analysis and display of used open-source libraries.
|
||||||
|
|
||||||
|
## 2025.1.0-alpha.1 (2025-08-28)
|
||||||
|
- Initial project setup and base structure.
|
||||||
55
TODO.md
Normal file
55
TODO.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
## MQTT TLS Hardening (Production)
|
||||||
|
|
||||||
|
- [ ] Enable TLS listener in `mosquitto/config/mosquitto.conf` (e.g., port 8883) while keeping 1883 only for temporary migration if needed.
|
||||||
|
- [ ] Generate and deploy server certificate + private key for Mosquitto (CA-signed or internal PKI).
|
||||||
|
- [ ] Add CA certificate distribution strategy for all clients and services (server, listener, scheduler, external monitors).
|
||||||
|
- [ ] Set strict file permissions for cert/key material (`chmod 600` for keys, least-privilege ownership).
|
||||||
|
- [ ] Update Docker Compose MQTT service to mount TLS cert/key/CA paths read-only.
|
||||||
|
- [ ] Add environment variables for TLS in `.env` / `.env.example`:
|
||||||
|
- `MQTT_TLS_ENABLED=true`
|
||||||
|
- `MQTT_TLS_CA_CERT=<path>`
|
||||||
|
- `MQTT_TLS_CERTFILE=<path>` (if mutual TLS used)
|
||||||
|
- `MQTT_TLS_KEYFILE=<path>` (if mutual TLS used)
|
||||||
|
- `MQTT_TLS_INSECURE=false`
|
||||||
|
- [ ] Switch internal services to TLS connection settings and verify authenticated reconnect behavior.
|
||||||
|
- [ ] Decide policy: TLS-only auth (username/password over TLS) vs mutual TLS + username/password.
|
||||||
|
- [ ] Disable non-TLS listener (1883) after all clients migrated.
|
||||||
|
- [ ] Restrict MQTT firewall ingress to trusted source ranges only.
|
||||||
|
- [ ] Add Mosquitto ACL file for topic-level permissions per role/client type.
|
||||||
|
- [ ] Add cert rotation process (renewal schedule, rollout, rollback steps).
|
||||||
|
- [ ] Add monitoring/alerting for certificate expiry and broker auth failures.
|
||||||
|
- [ ] Add runbook section for external monitoring clients (how to connect with CA validation).
|
||||||
|
- [ ] Perform a staged rollout (canary group first), then full migration.
|
||||||
|
- [ ] Document final TLS contract in `MQTT_EVENT_PAYLOAD_GUIDE.md` and deployment docs.
|
||||||
|
|
||||||
|
## Client Recovery Paths
|
||||||
|
|
||||||
|
### Path 1 — Software running → restart via MQTT ✅
|
||||||
|
- Server-side fully implemented (`restart_app` action, command lifecycle, monitoring panel).
|
||||||
|
- [ ] Client team: handle `restart_app` action in command handler (soft app restart, no reboot).
|
||||||
|
|
||||||
|
### Path 2 — Software crashed → MQTT unavailable
|
||||||
|
- Robust solution is **systemd `Restart=always`** (or `Restart=on-failure`) on the client device — no server involvement, OS init system restarts the process automatically.
|
||||||
|
- Server detects the crash via missing heartbeat (`process_status=crashed`), records it, and shows it in the monitoring panel. Recovery is confirmed when heartbeats resume.
|
||||||
|
- [ ] Client team: ensure the infoscreen service unit has `Restart=always` and `RestartSec=<delay>` configured in its systemd unit file.
|
||||||
|
- [ ] Evaluate whether MQTT `clean_session=False` + fixed `client_id` is worth adding for cases where the app crashes but the MQTT connection briefly survives (would allow QoS1 command delivery on reconnect).
|
||||||
|
- Note: the existing scheduler crash recovery (`reboot_host` via MQTT) is unreliable for a fully crashed app unless the client uses a persistent MQTT session. Revisit if client team enables `clean_session=False`.
|
||||||
|
|
||||||
|
### Path 3 — OS crashed / hung → power cycle needed (customer-dependent)
|
||||||
|
- No software-based recovery path is possible when the OS is unresponsive.
|
||||||
|
- Recovery requires external hardware intervention; options depend on customer infrastructure:
|
||||||
|
- Smart plug / PDU with API (e.g., Shelly, Tasmota, APC, Raritan)
|
||||||
|
- IPMI / iDRAC / BMC (server-class hardware)
|
||||||
|
- CEC power command from another device on the same HDMI chain
|
||||||
|
- Wake-on-LAN after a scheduled power-cut (limited applicability)
|
||||||
|
- [ ] Clarify with customer which hardware is available / acceptable.
|
||||||
|
- [ ] If a smart plug or PDU API is chosen: design a server-side "hard power cycle" command type and integration (out of scope until hardware is confirmed).
|
||||||
|
- [ ] Document chosen solution and integrate into monitoring runbook once decided.
|
||||||
|
|
||||||
|
## Optional Security Follow-ups
|
||||||
|
|
||||||
|
- [ ] Move MQTT credentials to Docker secrets or a vault-backed secret source.
|
||||||
|
- [ ] Rotate `MQTT_USER`/`MQTT_PASSWORD` on a fixed schedule.
|
||||||
|
- [ ] Add fail2ban/rate-limiting protections for exposed broker ports.
|
||||||
163
TV_POWER_INTENT_SERVER_CONTRACT_V1.md
Normal file
163
TV_POWER_INTENT_SERVER_CONTRACT_V1.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# TV Power Intent — Server Contract v1 (Phase 1)
|
||||||
|
|
||||||
|
> This document is the stable reference for client-side implementation.
|
||||||
|
> The server implementation is validated and frozen at this contract.
|
||||||
|
> Last validated: 2026-04-01
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Topic
|
||||||
|
|
||||||
|
```
|
||||||
|
infoscreen/groups/{group_id}/power/intent
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Scope**: group-level only (Phase 1). No per-client topic in Phase 1.
|
||||||
|
- **QoS**: 1
|
||||||
|
- **Retained**: true — broker holds last payload; client receives it immediately on (re)connect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Publish semantics
|
||||||
|
|
||||||
|
| Trigger | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| Semantic transition (state/reason changes) | New `intent_id`, immediate publish |
|
||||||
|
| No change (heartbeat) | Same `intent_id`, refreshed `issued_at` and `expires_at`, published every poll interval |
|
||||||
|
| Scheduler startup | Immediate publish before first poll wait |
|
||||||
|
| MQTT reconnect | Immediate retained republish of last known intent |
|
||||||
|
|
||||||
|
Poll interval default: **15 seconds** (dev) / **30 seconds** (prod).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Payload schema
|
||||||
|
|
||||||
|
All fields are always present. No optional fields for Phase 1 required fields.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"intent_id": "<uuid4>",
|
||||||
|
"group_id": <integer>,
|
||||||
|
"desired_state": "on" | "off",
|
||||||
|
"reason": "active_event" | "no_active_event",
|
||||||
|
"issued_at": "<ISO 8601 UTC with Z>",
|
||||||
|
"expires_at": "<ISO 8601 UTC with Z>",
|
||||||
|
"poll_interval_sec": <integer>,
|
||||||
|
"active_event_ids": [<integer>, ...],
|
||||||
|
"event_window_start": "<ISO 8601 UTC with Z>" | null,
|
||||||
|
"event_window_end": "<ISO 8601 UTC with Z>" | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field reference
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `schema_version` | string | Always `"1.0"` in Phase 1 |
|
||||||
|
| `intent_id` | string (uuid4) | Stable across heartbeats; new value on semantic transition |
|
||||||
|
| `group_id` | integer | Matches the MQTT topic group_id |
|
||||||
|
| `desired_state` | `"on"` or `"off"` | The commanded TV power state |
|
||||||
|
| `reason` | string | Human-readable reason for current state |
|
||||||
|
| `issued_at` | UTC Z string | When this payload was computed |
|
||||||
|
| `expires_at` | UTC Z string | After this time, payload is stale; re-subscribe or treat as `off` |
|
||||||
|
| `poll_interval_sec` | integer | Server poll interval; expiry = max(3 × poll, 90s) |
|
||||||
|
| `active_event_ids` | integer array | IDs of currently active events; empty when `off` |
|
||||||
|
| `event_window_start` | UTC Z string or null | Start of merged active coverage window; null when `off` |
|
||||||
|
| `event_window_end` | UTC Z string or null | End of merged active coverage window; null when `off` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Expiry rule
|
||||||
|
|
||||||
|
```
|
||||||
|
expires_at = issued_at + max(3 × poll_interval_sec, 90s)
|
||||||
|
```
|
||||||
|
|
||||||
|
Default at poll=15s → expiry window = **90 seconds**.
|
||||||
|
|
||||||
|
**Client rule**: if `now > expires_at` treat as stale and fall back to `off` until a fresh payload arrives.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example payloads
|
||||||
|
|
||||||
|
### ON (active event)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"intent_id": "4a7fe3bc-3654-48e3-b5b9-9fad1f7fead3",
|
||||||
|
"group_id": 2,
|
||||||
|
"desired_state": "on",
|
||||||
|
"reason": "active_event",
|
||||||
|
"issued_at": "2026-04-01T06:00:03.496Z",
|
||||||
|
"expires_at": "2026-04-01T06:01:33.496Z",
|
||||||
|
"poll_interval_sec": 15,
|
||||||
|
"active_event_ids": [148],
|
||||||
|
"event_window_start": "2026-04-01T06:00:00Z",
|
||||||
|
"event_window_end": "2026-04-01T07:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### OFF (no active event)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"intent_id": "833c53e3-d728-4604-9861-6ff7be1f227e",
|
||||||
|
"group_id": 2,
|
||||||
|
"desired_state": "off",
|
||||||
|
"reason": "no_active_event",
|
||||||
|
"issued_at": "2026-04-01T07:00:03.702Z",
|
||||||
|
"expires_at": "2026-04-01T07:01:33.702Z",
|
||||||
|
"poll_interval_sec": 15,
|
||||||
|
"active_event_ids": [],
|
||||||
|
"event_window_start": null,
|
||||||
|
"event_window_end": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validated server behaviours (client can rely on these)
|
||||||
|
|
||||||
|
| Scenario | Guaranteed server behaviour |
|
||||||
|
|---|---|
|
||||||
|
| Event starts | `desired_state: on` emitted within one poll interval |
|
||||||
|
| Event ends | `desired_state: off` emitted within one poll interval |
|
||||||
|
| Adjacent events (end1 == start2) | No intermediate `off` emitted at boundary |
|
||||||
|
| Overlapping events | `desired_state: on` held continuously |
|
||||||
|
| Scheduler restart during active event | Immediate `on` republish on reconnect; broker retained holds `on` during outage |
|
||||||
|
| No events in group | `desired_state: off` with empty `active_event_ids` |
|
||||||
|
| Heartbeat (no change) | Same `intent_id`, refreshed timestamps every poll |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client responsibilities (Phase 1)
|
||||||
|
|
||||||
|
1. **Subscribe** to `infoscreen/groups/{own_group_id}/power/intent` at QoS 1 on connect.
|
||||||
|
2. **Re-subscribe on reconnect** — broker retained message will deliver last known intent immediately.
|
||||||
|
3. **Parse `desired_state`** and apply TV power action (`on` → power on / `off` → power off).
|
||||||
|
4. **Deduplicate** using `intent_id` — if same `intent_id` received again, skip re-applying power command.
|
||||||
|
5. **Check expiry** — if `now > expires_at`, treat as stale and fall back to `off` until renewed.
|
||||||
|
6. **Ignore unknown fields** — for forward compatibility with Phase 2 additions.
|
||||||
|
7. **Do not use per-client topic** in Phase 1; only group topic is active.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Timestamps
|
||||||
|
|
||||||
|
- All timestamps use **ISO 8601 UTC with Z suffix**: `"2026-04-01T06:00:03.496Z"`
|
||||||
|
- Client must parse as UTC.
|
||||||
|
- Do not assume local time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 (deferred — not yet active)
|
||||||
|
|
||||||
|
- Per-client intent topic: `infoscreen/{client_uuid}/power/intent`
|
||||||
|
- Per-client override takes precedence over group intent
|
||||||
|
- Client state acknowledgement: `infoscreen/{client_uuid}/power/state`
|
||||||
|
- Listener persistence of client state to DB
|
||||||
324
WEBUNTIS_EVENT_IMPLEMENTATION.md
Normal file
324
WEBUNTIS_EVENT_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
# WebUntis Event Type Implementation
|
||||||
|
|
||||||
|
**Date**: 2025-10-19
|
||||||
|
**Status**: Completed
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Implemented support for a new `webuntis` event type that displays a centrally-configured WebUntis website on infoscreen clients. This event type follows the same client-side behavior as `website` events but sources its URL from system settings rather than per-event configuration.
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### 1. Database & Models
|
||||||
|
|
||||||
|
The `webuntis` event type was already defined in the `EventType` enum in `models/models.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class EventType(enum.Enum):
|
||||||
|
presentation = "presentation"
|
||||||
|
website = "website"
|
||||||
|
video = "video"
|
||||||
|
message = "message"
|
||||||
|
other = "other"
|
||||||
|
webuntis = "webuntis" # Already present
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. System Settings
|
||||||
|
|
||||||
|
#### Default Initialization (`server/init_defaults.py`)
|
||||||
|
|
||||||
|
Updated `supplement_table_url` description to indicate it's used for both Vertretungsplan and WebUntis:
|
||||||
|
|
||||||
|
```python
|
||||||
|
('supplement_table_url', '', 'URL für Vertretungsplan / WebUntis (Stundenplan-Änderungstabelle)')
|
||||||
|
```
|
||||||
|
|
||||||
|
This setting is automatically seeded during database initialization.
|
||||||
|
|
||||||
|
**Note**: The same URL (`supplement_table_url`) is used for both:
|
||||||
|
- Vertretungsplan (supplement table) displays
|
||||||
|
- WebUntis event displays
|
||||||
|
|
||||||
|
#### API Endpoints (`server/routes/system_settings.py`)
|
||||||
|
|
||||||
|
WebUntis events use the existing supplement table endpoints:
|
||||||
|
|
||||||
|
- **`GET /api/system-settings/supplement-table`** (Admin+)
|
||||||
|
- Returns: `{"url": "https://...", "enabled": true/false}`
|
||||||
|
|
||||||
|
- **`POST /api/system-settings/supplement-table`** (Admin+)
|
||||||
|
- Body: `{"url": "https://...", "enabled": true/false}`
|
||||||
|
- Updates the URL used for both supplement table and WebUntis events
|
||||||
|
|
||||||
|
No separate WebUntis URL endpoint is needed—the supplement table URL serves both purposes.
|
||||||
|
|
||||||
|
### 3. Event Creation (`server/routes/events.py`)
|
||||||
|
|
||||||
|
Added handling for `webuntis` event type in `create_event()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# WebUntis: URL aus System-Einstellungen holen und EventMedia anlegen
|
||||||
|
if event_type == "webuntis":
|
||||||
|
# Hole WebUntis-URL aus Systemeinstellungen (verwendet supplement_table_url)
|
||||||
|
webuntis_setting = session.query(SystemSetting).filter_by(key='supplement_table_url').first()
|
||||||
|
webuntis_url = webuntis_setting.value if webuntis_setting else ''
|
||||||
|
|
||||||
|
if not webuntis_url:
|
||||||
|
return jsonify({"error": "WebUntis / Supplement table URL not configured in system settings"}), 400
|
||||||
|
|
||||||
|
# EventMedia für WebUntis anlegen
|
||||||
|
media = EventMedia(
|
||||||
|
media_type=MediaType.website,
|
||||||
|
url=webuntis_url,
|
||||||
|
file_path=webuntis_url
|
||||||
|
)
|
||||||
|
session.add(media)
|
||||||
|
session.commit()
|
||||||
|
event_media_id = media.id
|
||||||
|
```
|
||||||
|
|
||||||
|
**Workflow**:
|
||||||
|
1. Check if `supplement_table_url` is configured in system settings
|
||||||
|
2. Return error if not configured
|
||||||
|
3. Create `EventMedia` with `MediaType.website` using the supplement table URL
|
||||||
|
4. Associate the media with the event
|
||||||
|
|
||||||
|
### 4. Scheduler Payload (`scheduler/db_utils.py`)
|
||||||
|
|
||||||
|
Modified `format_event_with_media()` to handle both `website` and `webuntis` events:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Handle website and webuntis events (both display a website)
|
||||||
|
elif event.event_type.value in ("website", "webuntis"):
|
||||||
|
event_dict["website"] = {
|
||||||
|
"type": "browser",
|
||||||
|
"url": media.url if media.url else None
|
||||||
|
}
|
||||||
|
if media.id not in _media_decision_logged:
|
||||||
|
logging.debug(
|
||||||
|
f"[Scheduler] Using website URL for event_media_id={media.id} (type={event.event_type.value}): {media.url}")
|
||||||
|
_media_decision_logged.add(media.id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Points**:
|
||||||
|
- Both event types use the same `website` payload structure
|
||||||
|
- Clients interpret `event_type` but handle display identically
|
||||||
|
- URL is already resolved from system settings during event creation
|
||||||
|
|
||||||
|
### 5. Documentation
|
||||||
|
|
||||||
|
Created comprehensive documentation in `MQTT_EVENT_PAYLOAD_GUIDE.md` covering:
|
||||||
|
- MQTT message structure
|
||||||
|
- Event type-specific payloads
|
||||||
|
- Best practices for client implementation
|
||||||
|
- Versioning strategy
|
||||||
|
- System settings integration
|
||||||
|
|
||||||
|
## MQTT Message Format
|
||||||
|
|
||||||
|
### WebUntis Event Payload
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 125,
|
||||||
|
"event_type": "webuntis",
|
||||||
|
"title": "Schedule Display",
|
||||||
|
"start": "2025-10-19T09:00:00+00:00",
|
||||||
|
"end": "2025-10-19T09:30:00+00:00",
|
||||||
|
"group_id": 1,
|
||||||
|
"website": {
|
||||||
|
"type": "browser",
|
||||||
|
"url": "https://webuntis.example.com/schedule"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Website Event Payload (for comparison)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 124,
|
||||||
|
"event_type": "website",
|
||||||
|
"title": "School Website",
|
||||||
|
"start": "2025-10-19T09:00:00+00:00",
|
||||||
|
"end": "2025-10-19T09:30:00+00:00",
|
||||||
|
"group_id": 1,
|
||||||
|
"website": {
|
||||||
|
"type": "browser",
|
||||||
|
"url": "https://example.com/page"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client Implementation Guide
|
||||||
|
|
||||||
|
Clients should handle both `website` and `webuntis` event types identically:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function parseEvent(event) {
|
||||||
|
switch (event.event_type) {
|
||||||
|
case 'presentation':
|
||||||
|
return handlePresentation(event.presentation);
|
||||||
|
|
||||||
|
case 'website':
|
||||||
|
case 'webuntis':
|
||||||
|
// Both types use the same display logic
|
||||||
|
return handleWebsite(event.website);
|
||||||
|
|
||||||
|
case 'video':
|
||||||
|
return handleVideo(event.video);
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn(`Unknown event type: ${event.event_type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWebsite(websiteData) {
|
||||||
|
// websiteData = { type: "browser", url: "https://..." }
|
||||||
|
if (!websiteData.url) {
|
||||||
|
console.error('Website event missing URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display URL in embedded browser/webview
|
||||||
|
displayInBrowser(websiteData.url);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Type-Based Dispatch
|
||||||
|
Always check `event_type` first and dispatch to appropriate handlers. The nested payload structure (`presentation`, `website`, etc.) provides type-specific details.
|
||||||
|
|
||||||
|
### 2. Graceful Error Handling
|
||||||
|
- Validate URLs before displaying
|
||||||
|
- Handle missing or empty URLs gracefully
|
||||||
|
- Provide user-friendly error messages
|
||||||
|
|
||||||
|
### 3. Unified Website Display
|
||||||
|
Both `website` and `webuntis` events trigger the same browser/webview component. The only difference is in event creation (per-event URL vs. system-wide URL).
|
||||||
|
|
||||||
|
### 4. Extensibility
|
||||||
|
The message structure supports adding new event types without breaking existing clients:
|
||||||
|
- Old clients ignore unknown `event_type` values
|
||||||
|
- New fields in existing payloads are optional
|
||||||
|
- Nested objects isolate type-specific changes
|
||||||
|
|
||||||
|
## Administrative Setup
|
||||||
|
|
||||||
|
### Setting the WebUntis / Supplement Table URL
|
||||||
|
|
||||||
|
The same URL is used for both Vertretungsplan (supplement table) and WebUntis displays.
|
||||||
|
|
||||||
|
1. **Via API** (recommended for UI integration):
|
||||||
|
```bash
|
||||||
|
POST /api/system-settings/supplement-table
|
||||||
|
{
|
||||||
|
"url": "https://webuntis.example.com/schedule",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Via Database** (for initial setup):
|
||||||
|
```sql
|
||||||
|
INSERT INTO system_settings (`key`, value, description)
|
||||||
|
VALUES ('supplement_table_url', 'https://webuntis.example.com/schedule',
|
||||||
|
'URL für Vertretungsplan / WebUntis (Stundenplan-Änderungstabelle)');
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Via Dashboard**:
|
||||||
|
Settings → Events → WebUntis / Vertretungsplan
|
||||||
|
|
||||||
|
### Creating a WebUntis Event
|
||||||
|
|
||||||
|
Once the URL is configured, events can be created through:
|
||||||
|
|
||||||
|
1. **Dashboard UI**: Select "WebUntis" as event type
|
||||||
|
2. **API**:
|
||||||
|
```json
|
||||||
|
POST /api/events
|
||||||
|
{
|
||||||
|
"group_id": 1,
|
||||||
|
"title": "Daily Schedule",
|
||||||
|
"description": "Current class schedule",
|
||||||
|
"start": "2025-10-19T08:00:00Z",
|
||||||
|
"end": "2025-10-19T16:00:00Z",
|
||||||
|
"event_type": "webuntis",
|
||||||
|
"created_by": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No `website_url` is required—it's automatically fetched from the `supplement_table_url` system setting.
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
### From Presentation-Only System
|
||||||
|
|
||||||
|
This implementation extends the existing event system without breaking presentation events:
|
||||||
|
|
||||||
|
- **Presentation events**: Still use `presentation` payload with `files` array
|
||||||
|
- **Website/WebUntis events**: Use new `website` payload with `url` field
|
||||||
|
- **Message structure**: Includes `event_type` for client-side dispatch
|
||||||
|
|
||||||
|
### Future Event Types
|
||||||
|
|
||||||
|
The pattern established here can be extended to other event types:
|
||||||
|
|
||||||
|
- **Video**: `event_dict["video"] = { "type": "media", "url": "...", "autoplay": true }`
|
||||||
|
- **Message**: `event_dict["message"] = { "type": "html", "content": "..." }`
|
||||||
|
- **Custom**: Any new type with its own nested payload
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [x] Database migration includes `webuntis` enum value
|
||||||
|
- [x] System setting `supplement_table_url` description updated to include WebUntis
|
||||||
|
- [x] Event creation validates supplement_table_url is configured
|
||||||
|
- [x] Event creation creates `EventMedia` with supplement table URL
|
||||||
|
- [x] Scheduler includes `website` payload for `webuntis` events
|
||||||
|
- [x] MQTT message structure documented
|
||||||
|
- [x] No duplicate webuntis_url setting (uses supplement_table_url)
|
||||||
|
- [ ] Dashboard UI shows supplement table URL is used for WebUntis (documentation)
|
||||||
|
- [ ] Client implementation tested with WebUntis events (client-side)
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
### Modified
|
||||||
|
- `scheduler/db_utils.py` - Event formatting logic
|
||||||
|
- `server/routes/events.py` - Event creation handling
|
||||||
|
- `server/routes/system_settings.py` - WebUntis URL endpoints
|
||||||
|
- `server/init_defaults.py` - System setting defaults
|
||||||
|
|
||||||
|
### Created
|
||||||
|
- `MQTT_EVENT_PAYLOAD_GUIDE.md` - Comprehensive message format documentation
|
||||||
|
- `WEBUNTIS_EVENT_IMPLEMENTATION.md` - This file
|
||||||
|
|
||||||
|
### Existing (Not Modified)
|
||||||
|
- `models/models.py` - Already had `webuntis` enum value
|
||||||
|
- `dashboard/src/components/CustomEventModal.tsx` - Already supports webuntis type
|
||||||
|
|
||||||
|
## Further Enhancements
|
||||||
|
|
||||||
|
### Short-term
|
||||||
|
1. Add WebUntis URL configuration to dashboard Settings page
|
||||||
|
2. Update event creation UI to explain WebUntis URL comes from settings
|
||||||
|
3. Add validation/preview for WebUntis URL in settings
|
||||||
|
|
||||||
|
### Long-term
|
||||||
|
1. Support multiple WebUntis instances (per-school in multi-tenant setup)
|
||||||
|
2. Add WebUntis-specific metadata (class filter, room filter, etc.)
|
||||||
|
3. Implement iframe sandboxing options for security
|
||||||
|
4. Add refresh intervals for dynamic WebUntis content
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The `webuntis` event type is now fully integrated into the infoscreen system. It uses the existing `supplement_table_url` system setting, which serves dual purposes:
|
||||||
|
1. **Vertretungsplan (supplement table)** displays in the existing settings UI
|
||||||
|
2. **WebUntis schedule** displays via the webuntis event type
|
||||||
|
|
||||||
|
This provides a clean separation between system-wide URL configuration and per-event scheduling, while maintaining backward compatibility and following established patterns for event payload structure.
|
||||||
|
|
||||||
|
The implementation demonstrates best practices:
|
||||||
|
- **Reuse existing infrastructure**: Uses supplement_table_url instead of creating duplicate settings
|
||||||
|
- **Consistency**: Follows same patterns as existing event types
|
||||||
|
- **Extensibility**: Easy to add new event types following this model
|
||||||
|
- **Documentation**: Comprehensive guides for both developers and clients
|
||||||
Binary file not shown.
@@ -1,38 +0,0 @@
|
|||||||
# dashboard/Dockerfile
|
|
||||||
# Produktions-Dockerfile für die Dash-Applikation
|
|
||||||
|
|
||||||
# --- Basis-Image ---
|
|
||||||
FROM python:3.13-slim
|
|
||||||
|
|
||||||
# --- Arbeitsverzeichnis im Container ---
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# --- Systemabhängigkeiten installieren (falls benötigt) ---
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends \
|
|
||||||
build-essential git \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# --- Python-Abhängigkeiten kopieren und installieren ---
|
|
||||||
COPY dash_using_fullcalendar-0.1.0.tar.gz ./
|
|
||||||
COPY requirements.txt ./
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# --- Applikationscode kopieren ---
|
|
||||||
COPY dashboard/ /app
|
|
||||||
|
|
||||||
# --- Non-Root-User anlegen und Rechte setzen ---
|
|
||||||
ARG USER_ID=1000
|
|
||||||
ARG GROUP_ID=1000
|
|
||||||
RUN groupadd -g ${GROUP_ID} infoscreen_taa \
|
|
||||||
&& useradd -u ${USER_ID} -g ${GROUP_ID} \
|
|
||||||
--shell /bin/bash --create-home infoscreen_taa \
|
|
||||||
&& chown -R infoscreen_taa:infoscreen_taa /app
|
|
||||||
USER infoscreen_taa
|
|
||||||
|
|
||||||
# --- Port für Dash exposed ---
|
|
||||||
EXPOSE 8050
|
|
||||||
|
|
||||||
# --- Startbefehl: Gunicorn mit Dash-Server ---
|
|
||||||
# "app.py" enthält: app = dash.Dash(...); server = app.server
|
|
||||||
CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:8050", "app:server"]
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# dashboard/Dockerfile.dev
|
|
||||||
# Entwicklungs-Dockerfile für das Dash-Dashboard
|
|
||||||
|
|
||||||
FROM python:3.13-slim
|
|
||||||
|
|
||||||
# Build args für UID/GID
|
|
||||||
ARG USER_ID=1000
|
|
||||||
ARG GROUP_ID=1000
|
|
||||||
|
|
||||||
# Systemabhängigkeiten (falls nötig)
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends locales curl \
|
|
||||||
&& sed -i 's/# de_DE.UTF-8 UTF-8/de_DE.UTF-8 UTF-8/' /etc/locale.gen \
|
|
||||||
&& locale-gen \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Locale setzen
|
|
||||||
ENV LANG=de_DE.UTF-8 \
|
|
||||||
LANGUAGE=de_DE:de \
|
|
||||||
LC_ALL=de_DE.UTF-8
|
|
||||||
|
|
||||||
# Non-root User anlegen
|
|
||||||
RUN groupadd -g ${GROUP_ID} infoscreen_taa \
|
|
||||||
&& useradd -u ${USER_ID} -g ${GROUP_ID} --shell /bin/bash --create-home infoscreen_taa
|
|
||||||
|
|
||||||
# Arbeitsverzeichnis
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Kopiere nur Requirements für schnellen Rebuild
|
|
||||||
COPY dash_using_fullcalendar-0.1.0.tar.gz ./
|
|
||||||
COPY requirements.txt ./
|
|
||||||
COPY requirements-dev.txt ./
|
|
||||||
|
|
||||||
# Installiere Abhängigkeiten
|
|
||||||
RUN pip install --upgrade pip \
|
|
||||||
&& pip install --no-cache-dir -r requirements.txt \
|
|
||||||
&& pip install --no-cache-dir -r requirements-dev.txt
|
|
||||||
|
|
||||||
# Setze Entwicklungs-Modus
|
|
||||||
ENV DASH_DEBUG_MODE=True
|
|
||||||
ENV API_URL=http://server:8000/api
|
|
||||||
|
|
||||||
# Ports für Dash und optional Live-Reload
|
|
||||||
EXPOSE 8050
|
|
||||||
EXPOSE 5678
|
|
||||||
|
|
||||||
# Wechsle zum non-root User
|
|
||||||
USER infoscreen_taa
|
|
||||||
|
|
||||||
# Dev-Start: Dash mit Hot-Reload
|
|
||||||
CMD ["python", "-m", "debugpy", "--listen", "0.0.0.0:5637", "app.py"]
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# dashboard/app.py
|
|
||||||
import sys
|
|
||||||
sys.path.append('/workspace')
|
|
||||||
|
|
||||||
from dash import Dash, html, dcc, page_container
|
|
||||||
from flask import Flask
|
|
||||||
import dash_bootstrap_components as dbc
|
|
||||||
import dash_mantine_components as dmc
|
|
||||||
from components.header import Header
|
|
||||||
import callbacks.ui_callbacks
|
|
||||||
import dashboard.callbacks.overview_callbacks
|
|
||||||
import dashboard.callbacks.appointments_callbacks
|
|
||||||
import dashboard.callbacks.appointment_modal_callbacks
|
|
||||||
from config import SECRET_KEY, ENV
|
|
||||||
import os
|
|
||||||
import threading
|
|
||||||
import logging
|
|
||||||
|
|
||||||
# Logging konfigurieren
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.DEBUG if ENV == "development" else logging.INFO,
|
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
||||||
)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
server = Flask(__name__)
|
|
||||||
server.secret_key = SECRET_KEY
|
|
||||||
|
|
||||||
# Flask's eigene Logs aktivieren
|
|
||||||
if ENV == "development":
|
|
||||||
logging.getLogger('werkzeug').setLevel(logging.INFO)
|
|
||||||
|
|
||||||
app = Dash(
|
|
||||||
__name__,
|
|
||||||
server=server,
|
|
||||||
use_pages=True,
|
|
||||||
external_stylesheets=[dbc.themes.BOOTSTRAP],
|
|
||||||
suppress_callback_exceptions=True,
|
|
||||||
serve_locally=True,
|
|
||||||
meta_tags=[
|
|
||||||
{"name": "viewport", "content": "width=device-width, initial-scale=1"},
|
|
||||||
{"charset": "utf-8"}
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
app.layout = dmc.MantineProvider([
|
|
||||||
Header(),
|
|
||||||
html.Div([
|
|
||||||
html.Div(id="sidebar"),
|
|
||||||
html.Div(page_container, className="page-content"),
|
|
||||||
dcc.Store(id="sidebar-state", data={"collapsed": False}),
|
|
||||||
], style={"display": "flex"}),
|
|
||||||
])
|
|
||||||
|
|
||||||
# def open_browser():
|
|
||||||
# """Öffnet die HTTPS-URL im Standardbrowser."""
|
|
||||||
# os.system('$BROWSER https://localhost:8050') # Entferne das "&", um sicherzustellen, dass der Browser korrekt geöffnet wird
|
|
||||||
|
|
||||||
print("Testausgabe: Debug-Print funktioniert!") # Testausgabe
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
debug_mode = ENV == "development"
|
|
||||||
|
|
||||||
logger.info(f"Starting application in {'DEBUG' if debug_mode else 'PRODUCTION'} mode")
|
|
||||||
logger.info(f"Environment: {ENV}")
|
|
||||||
logger.info("🔧 Debug features: print(), logging, hot reload all active")
|
|
||||||
logger.info("🚀 Dashboard starting up...")
|
|
||||||
|
|
||||||
# Browser nur einmal öffnen, nicht bei Reload-Prozessen
|
|
||||||
# if debug_mode and os.environ.get("WERKZEUG_RUN_MAIN") != "true":
|
|
||||||
# threading.Timer(1.0, open_browser).start()
|
|
||||||
|
|
||||||
app.run(
|
|
||||||
host="0.0.0.0",
|
|
||||||
port=8050,
|
|
||||||
debug=debug_mode,
|
|
||||||
ssl_context=("/workspace/certs/dev.crt", "/workspace/certs/dev.key"),
|
|
||||||
use_reloader=False # Verhindert doppeltes Öffnen durch Dash
|
|
||||||
)
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
@@ -1,249 +0,0 @@
|
|||||||
/* ==========================
|
|
||||||
Allgemeines Layout
|
|
||||||
========================== */
|
|
||||||
:root {
|
|
||||||
--mb-z-index: 2000 !important;
|
|
||||||
--mantine-z-index-popover: 2100 !important;
|
|
||||||
--mantine-z-index-overlay: 2999 !important;
|
|
||||||
--mantine-z-index-dropdown: 2100 !important;
|
|
||||||
--mantine-z-index-max: 9999 !important;
|
|
||||||
--mantine-z-index-modal: 3000 !important;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
padding-top: 60px; /* Platz für den fixen Header schaffen */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* page-content (rechts neben der Sidebar) */
|
|
||||||
.page-content {
|
|
||||||
flex: 1 1 0%;
|
|
||||||
padding: 20px;
|
|
||||||
min-width: 0; /* verhindert Überlauf bei zu breitem Inhalt */
|
|
||||||
transition: margin-left 0.3s ease;
|
|
||||||
min-height: calc(100vh - 60px); /* Mindesthöhe minus Header-Höhe */
|
|
||||||
margin-left: 220px; /* <--- Ergänzen */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Wenn Sidebar collapsed ist, reduziere margin-left */
|
|
||||||
.sidebar.collapsed ~ .page-content {
|
|
||||||
margin-left: 70px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==========================
|
|
||||||
Header
|
|
||||||
========================== */
|
|
||||||
.app-header {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
height: 60px;
|
|
||||||
width: 100%;
|
|
||||||
background-color: #e4d5c1;
|
|
||||||
color: #7c5617;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 20px;
|
|
||||||
z-index: 1100;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 40px;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.org-name {
|
|
||||||
font-size: 1rem;
|
|
||||||
color: #7c5617;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==========================
|
|
||||||
Sidebar
|
|
||||||
========================== */
|
|
||||||
.sidebar {
|
|
||||||
width: 220px;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
background-color: #e4d5c1;
|
|
||||||
color: black;
|
|
||||||
height: calc(100vh - 60px); /* Höhe minus Header */
|
|
||||||
top: 60px; /* Den gleichen Wert wie Header-Höhe verwenden */
|
|
||||||
left: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
position: fixed; /* <--- Ändere das von relative zu fixed */
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.collapsed {
|
|
||||||
width: 70px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Toggle Button (Burger-Icon) */
|
|
||||||
.sidebar-toggle {
|
|
||||||
text-align: right;
|
|
||||||
padding: 5px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-button {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #7c5617;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
transition: transform 0.1s ease, background-color 0.2s ease;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-button:hover {
|
|
||||||
background-color: #7c5617;
|
|
||||||
color: #e4d5c1;
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-button:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navigation in der Sidebar */
|
|
||||||
.sidebar-nav .nav-link {
|
|
||||||
color: #7c5617;
|
|
||||||
padding: 10px 15px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 2px 8px;
|
|
||||||
transition: background-color 0.2s ease, color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-nav .nav-link:hover {
|
|
||||||
background-color: #7c5617;
|
|
||||||
color: #e4d5c1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-nav .nav-link.active {
|
|
||||||
background-color: #7c5617;
|
|
||||||
color: #e4d5c1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text neben Icons */
|
|
||||||
.sidebar-label {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 10px;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: opacity 0.3s ease, width 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Wenn Sidebar collapsed ist, blendet das Label aus */
|
|
||||||
.sidebar.collapsed .sidebar-label {
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tooltips (Bootstrap-Tooltips) */
|
|
||||||
.tooltip {
|
|
||||||
z-index: 2000;
|
|
||||||
background-color: #7c5617;
|
|
||||||
color: #e4d5c1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Optional: Tooltips nur anzeigen, wenn Sidebar collapsed ist */
|
|
||||||
/* Da dash-bootstrap-components Tooltips in einen anderen DOM-Layer rendert,
|
|
||||||
kann man bei Bedarf per Callback steuern, ob sie geöffnet sind oder nicht.
|
|
||||||
Dieser Block ist nur ein Zusatz – das Haupt-Show/Hiding erfolgt per
|
|
||||||
is_open-Callback. */
|
|
||||||
.sidebar:not(.collapsed) ~ .tooltip {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==========================
|
|
||||||
Responsive (bei Bedarf)
|
|
||||||
========================== */
|
|
||||||
/* @media (max-width: 768px) {
|
|
||||||
body {
|
|
||||||
padding-top: 60px; /* Header-Platz auch auf mobilen Geräten */
|
|
||||||
/* }
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
position: fixed;
|
|
||||||
height: calc(100vh - 60px);
|
|
||||||
z-index: 1050;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.collapsed {
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.collapsed ~ .page-content {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
} */
|
|
||||||
|
|
||||||
.mantine-Modal-modal {
|
|
||||||
z-index: var(--mantine-z-index-modal, 3000) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modalbox */
|
|
||||||
.mantine-Modal-inner,
|
|
||||||
.mantine-Modal-content {
|
|
||||||
z-index: 4000 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Popups (Dropdowns, Datepicker, Autocomplete, Menüs) innerhalb der Modalbox */
|
|
||||||
.mantine-Popover-dropdown,
|
|
||||||
.mantine-Select-dropdown,
|
|
||||||
.mantine-DatePicker-dropdown,
|
|
||||||
.mantine-Autocomplete-dropdown,
|
|
||||||
.mantine-Menu-dropdown {
|
|
||||||
z-index: 4100 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Optional: Overlay für Popups noch höher, falls benötigt */
|
|
||||||
.mantine-Popover-root,
|
|
||||||
.mantine-Select-root,
|
|
||||||
.mantine-DatePicker-root,
|
|
||||||
.mantine-Autocomplete-root,
|
|
||||||
.mantine-Menu-root {
|
|
||||||
z-index: 4101 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar collapsed: Icon-Farbe normal */
|
|
||||||
.sidebar.collapsed .sidebar-item-collapsed svg {
|
|
||||||
color: #7c5617; /* Icon-Linie/Text */
|
|
||||||
fill: #e4d5c1; /* Icon-Fläche */
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: block;
|
|
||||||
transition: color 0.2s, fill 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar collapsed: Hintergrund und Icon invertieren bei Hover/Active */
|
|
||||||
.sidebar.collapsed .nav-link:hover,
|
|
||||||
.sidebar.collapsed .nav-link.active {
|
|
||||||
background-color: #7c5617 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.collapsed .nav-link:hover svg,
|
|
||||||
.sidebar.collapsed .nav-link.active svg {
|
|
||||||
color: #e4d5c1; /* Icon-Linie/Text invertiert */
|
|
||||||
fill: #7c5617; /* Icon-Fläche invertiert */
|
|
||||||
}
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
from dash import Input, Output, State, callback, html, dcc, no_update, ctx
|
|
||||||
import dash_mantine_components as dmc
|
|
||||||
from dash_iconify import DashIconify
|
|
||||||
import dash_quill
|
|
||||||
from datetime import datetime, date, timedelta
|
|
||||||
import re
|
|
||||||
|
|
||||||
# --- Typ-spezifische Felder anzeigen ---
|
|
||||||
@callback(
|
|
||||||
Output('type-specific-fields', 'children'),
|
|
||||||
Input('type-input', 'value'),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def show_type_specific_fields(event_type):
|
|
||||||
if not event_type:
|
|
||||||
return html.Div()
|
|
||||||
|
|
||||||
if event_type == "presentation":
|
|
||||||
return dmc.Stack([
|
|
||||||
dmc.Divider(label="Präsentations-Details", labelPosition="center"),
|
|
||||||
dmc.Group([
|
|
||||||
dcc.Upload(
|
|
||||||
id='presentation-upload',
|
|
||||||
children=dmc.Button(
|
|
||||||
"Datei hochladen",
|
|
||||||
leftSection=DashIconify(icon="mdi:upload"),
|
|
||||||
variant="outline"
|
|
||||||
),
|
|
||||||
style={'width': 'auto'}
|
|
||||||
),
|
|
||||||
dmc.TextInput(
|
|
||||||
label="Präsentationslink",
|
|
||||||
placeholder="https://...",
|
|
||||||
leftSection=DashIconify(icon="mdi:link"),
|
|
||||||
id="presentation-link",
|
|
||||||
style={"minWidth": 0, "flex": 1, "marginBottom": 0}
|
|
||||||
)
|
|
||||||
], grow=True, align="flex-end", style={"marginBottom": 10}),
|
|
||||||
html.Div(id="presentation-upload-status")
|
|
||||||
], gap="sm")
|
|
||||||
|
|
||||||
elif event_type == "video":
|
|
||||||
return dmc.Stack([
|
|
||||||
dmc.Divider(label="Video-Details", labelPosition="center"),
|
|
||||||
dmc.Group([
|
|
||||||
dcc.Upload(
|
|
||||||
id='video-upload',
|
|
||||||
children=dmc.Button(
|
|
||||||
"Video hochladen",
|
|
||||||
leftSection=DashIconify(icon="mdi:video-plus"),
|
|
||||||
variant="outline"
|
|
||||||
),
|
|
||||||
style={'width': 'auto'}
|
|
||||||
),
|
|
||||||
dmc.TextInput(
|
|
||||||
label="Videolink",
|
|
||||||
placeholder="https://youtube.com/...",
|
|
||||||
leftSection=DashIconify(icon="mdi:youtube"),
|
|
||||||
id="video-link",
|
|
||||||
style={"minWidth": 0, "flex": 1, "marginBottom": 0}
|
|
||||||
)
|
|
||||||
], grow=True, align="flex-end", style={"marginBottom": 10}),
|
|
||||||
dmc.Group([
|
|
||||||
dmc.Checkbox(
|
|
||||||
label="Endlos wiederholen",
|
|
||||||
id="video-endless",
|
|
||||||
checked=True,
|
|
||||||
style={"marginRight": 20}
|
|
||||||
),
|
|
||||||
dmc.NumberInput(
|
|
||||||
label="Wiederholungen",
|
|
||||||
id="video-repeats",
|
|
||||||
value=1,
|
|
||||||
min=1,
|
|
||||||
max=99,
|
|
||||||
step=1,
|
|
||||||
disabled=True,
|
|
||||||
style={"width": 150}
|
|
||||||
),
|
|
||||||
dmc.Slider(
|
|
||||||
label="Lautstärke",
|
|
||||||
id="video-volume",
|
|
||||||
value=70,
|
|
||||||
min=0,
|
|
||||||
max=100,
|
|
||||||
step=5,
|
|
||||||
marks=[
|
|
||||||
{"value": 0, "label": "0%"},
|
|
||||||
{"value": 50, "label": "50%"},
|
|
||||||
{"value": 100, "label": "100%"}
|
|
||||||
],
|
|
||||||
style={"flex": 1, "marginLeft": 20}
|
|
||||||
)
|
|
||||||
], grow=True, align="flex-end"),
|
|
||||||
html.Div(id="video-upload-status")
|
|
||||||
], gap="sm")
|
|
||||||
|
|
||||||
elif event_type == "website":
|
|
||||||
return dmc.Stack([
|
|
||||||
dmc.Divider(label="Website-Details", labelPosition="center"),
|
|
||||||
dmc.TextInput(
|
|
||||||
label="Website-URL",
|
|
||||||
placeholder="https://example.com",
|
|
||||||
leftSection=DashIconify(icon="mdi:web"),
|
|
||||||
id="website-url",
|
|
||||||
required=True,
|
|
||||||
style={"flex": 1}
|
|
||||||
)
|
|
||||||
], gap="sm")
|
|
||||||
|
|
||||||
elif event_type == "message":
|
|
||||||
return dmc.Stack([
|
|
||||||
dmc.Divider(label="Nachrichten-Details", labelPosition="center"),
|
|
||||||
dash_quill.Quill(
|
|
||||||
id="message-content",
|
|
||||||
value="",
|
|
||||||
),
|
|
||||||
dmc.Group([
|
|
||||||
dmc.Select(
|
|
||||||
label="Schriftgröße",
|
|
||||||
data=[
|
|
||||||
{"value": "small", "label": "Klein"},
|
|
||||||
{"value": "medium", "label": "Normal"},
|
|
||||||
{"value": "large", "label": "Groß"},
|
|
||||||
{"value": "xlarge", "label": "Sehr groß"}
|
|
||||||
],
|
|
||||||
id="message-font-size",
|
|
||||||
value="medium",
|
|
||||||
style={"flex": 1}
|
|
||||||
),
|
|
||||||
dmc.ColorPicker(
|
|
||||||
id="message-color",
|
|
||||||
value="#000000",
|
|
||||||
format="hex",
|
|
||||||
swatches=[
|
|
||||||
"#000000", "#ffffff", "#ff0000", "#00ff00",
|
|
||||||
"#0000ff", "#ffff00", "#ff00ff", "#00ffff"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
], grow=True, align="flex-end")
|
|
||||||
], gap="sm")
|
|
||||||
|
|
||||||
return html.Div()
|
|
||||||
|
|
||||||
# --- Callback zum Aktivieren/Deaktivieren des Wiederholungsfelds bei Video ---
|
|
||||||
@callback(
|
|
||||||
Output("video-repeats", "disabled"),
|
|
||||||
Input("video-endless", "checked"),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def toggle_video_repeats(endless_checked):
|
|
||||||
return endless_checked
|
|
||||||
|
|
||||||
# --- Upload-Status für Präsentation ---
|
|
||||||
@callback(
|
|
||||||
Output('presentation-upload-status', 'children'),
|
|
||||||
Input('presentation-upload', 'contents'),
|
|
||||||
State('presentation-upload', 'filename'),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def update_presentation_upload_status(contents, filename):
|
|
||||||
if contents is not None and filename is not None:
|
|
||||||
return dmc.Alert(
|
|
||||||
f"✓ Datei '{filename}' erfolgreich hochgeladen",
|
|
||||||
color="green",
|
|
||||||
className="mt-2"
|
|
||||||
)
|
|
||||||
return html.Div()
|
|
||||||
|
|
||||||
# --- Upload-Status für Video ---
|
|
||||||
@callback(
|
|
||||||
Output('video-upload-status', 'children'),
|
|
||||||
Input('video-upload', 'contents'),
|
|
||||||
State('video-upload', 'filename'),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def update_video_upload_status(contents, filename):
|
|
||||||
if contents is not None and filename is not None:
|
|
||||||
return dmc.Alert(
|
|
||||||
f"✓ Video '{filename}' erfolgreich hochgeladen",
|
|
||||||
color="green",
|
|
||||||
className="mt-2"
|
|
||||||
)
|
|
||||||
return html.Div()
|
|
||||||
|
|
||||||
# --- Wiederholungsoptionen aktivieren/deaktivieren ---
|
|
||||||
@callback(
|
|
||||||
[
|
|
||||||
Output('weekdays-select', 'disabled'),
|
|
||||||
Output('repeat-until-date', 'disabled'),
|
|
||||||
Output('skip-holidays-checkbox', 'disabled'),
|
|
||||||
Output('weekdays-select', 'value'),
|
|
||||||
Output('repeat-until-date', 'value'),
|
|
||||||
Output('skip-holidays-checkbox', 'checked')
|
|
||||||
],
|
|
||||||
Input('repeat-checkbox', 'checked'),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def toggle_repeat_options(is_repeat):
|
|
||||||
if is_repeat:
|
|
||||||
next_month = datetime.now().date() + timedelta(weeks=4)
|
|
||||||
return (
|
|
||||||
False, # weekdays-select enabled
|
|
||||||
False, # repeat-until-date enabled
|
|
||||||
False, # skip-holidays-checkbox enabled
|
|
||||||
None, # weekdays value
|
|
||||||
next_month, # repeat-until-date value
|
|
||||||
False # skip-holidays-checkbox checked
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return (
|
|
||||||
True, # weekdays-select disabled
|
|
||||||
True, # repeat-until-date disabled
|
|
||||||
True, # skip-holidays-checkbox disabled
|
|
||||||
None, # weekdays value
|
|
||||||
None, # repeat-until-date value
|
|
||||||
False # skip-holidays-checkbox checked
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Dynamische Zeitoptionen für Startzeit ---
|
|
||||||
def validate_and_format_time(time_str):
|
|
||||||
if not time_str:
|
|
||||||
return None, "Keine Zeit angegeben"
|
|
||||||
if re.match(r'^\d{2}:\d{2}$', time_str):
|
|
||||||
try:
|
|
||||||
h, m = map(int, time_str.split(':'))
|
|
||||||
if 0 <= h <= 23 and 0 <= m <= 59:
|
|
||||||
return time_str, "Gültige Zeit"
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
patterns = [
|
|
||||||
(r'^(\d{1,2}):(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
|
|
||||||
(r'^(\d{1,2})\.(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
|
|
||||||
(r'^(\d{1,2})(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
|
|
||||||
(r'^(\d{1,2})$', lambda m: (int(m.group(1)), 0)),
|
|
||||||
]
|
|
||||||
for pattern, extractor in patterns:
|
|
||||||
match = re.match(pattern, time_str.strip())
|
|
||||||
if match:
|
|
||||||
try:
|
|
||||||
hours, minutes = extractor(match)
|
|
||||||
if 0 <= hours <= 23 and 0 <= minutes <= 59:
|
|
||||||
return f"{hours:02d}:{minutes:02d}", "Automatisch formatiert"
|
|
||||||
except:
|
|
||||||
continue
|
|
||||||
return None, "Ungültiges Zeitformat"
|
|
||||||
|
|
||||||
@callback(
|
|
||||||
[
|
|
||||||
Output('time-start', 'data'),
|
|
||||||
Output('start-time-feedback', 'children')
|
|
||||||
],
|
|
||||||
Input('time-start', 'searchValue'),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def update_start_time_options(search_value):
|
|
||||||
time_options = [
|
|
||||||
{"value": f"{h:02d}:{m:02d}", "label": f"{h:02d}:{m:02d}"}
|
|
||||||
for h in range(6, 24) for m in [0, 30]
|
|
||||||
]
|
|
||||||
base_options = time_options.copy()
|
|
||||||
feedback = None
|
|
||||||
if search_value:
|
|
||||||
validated_time, status = validate_and_format_time(search_value)
|
|
||||||
if validated_time:
|
|
||||||
if not any(opt["value"] == validated_time for opt in base_options):
|
|
||||||
base_options.insert(0, {
|
|
||||||
"value": validated_time,
|
|
||||||
"label": f"{validated_time} (Ihre Eingabe)"
|
|
||||||
})
|
|
||||||
feedback = dmc.Text(f"✓ {status}: {validated_time}", size="xs", c="green")
|
|
||||||
else:
|
|
||||||
feedback = dmc.Text(f"✗ {status}", size="xs", c="red")
|
|
||||||
return base_options, feedback
|
|
||||||
|
|
||||||
# --- Dynamische Zeitoptionen für Endzeit ---
|
|
||||||
@callback(
|
|
||||||
[
|
|
||||||
Output('time-end', 'data'),
|
|
||||||
Output('end-time-feedback', 'children')
|
|
||||||
],
|
|
||||||
Input('time-end', 'searchValue'),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def update_end_time_options(search_value):
|
|
||||||
time_options = [
|
|
||||||
{"value": f"{h:02d}:{m:02d}", "label": f"{h:02d}:{m:02d}"}
|
|
||||||
for h in range(6, 24) for m in [0, 30]
|
|
||||||
]
|
|
||||||
base_options = time_options.copy()
|
|
||||||
feedback = None
|
|
||||||
if search_value:
|
|
||||||
validated_time, status = validate_and_format_time(search_value)
|
|
||||||
if validated_time:
|
|
||||||
if not any(opt["value"] == validated_time for opt in base_options):
|
|
||||||
base_options.insert(0, {
|
|
||||||
"value": validated_time,
|
|
||||||
"label": f"{validated_time} (Ihre Eingabe)"
|
|
||||||
})
|
|
||||||
feedback = dmc.Text(f"✓ {status}: {validated_time}", size="xs", c="green")
|
|
||||||
else:
|
|
||||||
feedback = dmc.Text(f"✗ {status}", size="xs", c="red")
|
|
||||||
return base_options, feedback
|
|
||||||
|
|
||||||
# --- Automatische Endzeit-Berechnung mit Validation ---
|
|
||||||
@callback(
|
|
||||||
Output('time-end', 'value'),
|
|
||||||
[
|
|
||||||
Input('time-start', 'value'),
|
|
||||||
Input('btn-reset', 'n_clicks')
|
|
||||||
],
|
|
||||||
State('time-end', 'value'),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def handle_end_time(start_time, reset_clicks, current_end_time):
|
|
||||||
if not ctx.triggered:
|
|
||||||
return no_update
|
|
||||||
trigger_id = ctx.triggered[0]['prop_id'].split('.')[0]
|
|
||||||
if trigger_id == 'btn-reset' and reset_clicks:
|
|
||||||
return None
|
|
||||||
if trigger_id == 'time-start' and start_time:
|
|
||||||
if current_end_time:
|
|
||||||
return no_update
|
|
||||||
try:
|
|
||||||
validated_start, _ = validate_and_format_time(start_time)
|
|
||||||
if validated_start:
|
|
||||||
start_dt = datetime.strptime(validated_start, "%H:%M")
|
|
||||||
end_dt = start_dt + timedelta(hours=1, minutes=30)
|
|
||||||
if end_dt.hour >= 24:
|
|
||||||
end_dt = end_dt.replace(hour=23, minute=59)
|
|
||||||
return end_dt.strftime("%H:%M")
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
return no_update
|
|
||||||
|
|
||||||
# --- Reset-Funktion erweitert ---
|
|
||||||
@callback(
|
|
||||||
[
|
|
||||||
Output('title-input', 'value'),
|
|
||||||
Output('start-date-input', 'value', allow_duplicate=True),
|
|
||||||
Output('time-start', 'value'),
|
|
||||||
Output('type-input', 'value'),
|
|
||||||
Output('description-input', 'value'),
|
|
||||||
Output('repeat-checkbox', 'checked'),
|
|
||||||
Output('weekdays-select', 'value', allow_duplicate=True),
|
|
||||||
Output('repeat-until-date', 'value', allow_duplicate=True),
|
|
||||||
Output('skip-holidays-checkbox', 'checked', allow_duplicate=True)
|
|
||||||
],
|
|
||||||
Input('btn-reset', 'n_clicks'),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def reset_form(n_clicks):
|
|
||||||
if n_clicks:
|
|
||||||
return "", datetime.now().date(), "09:00", None, "", False, None, None, False
|
|
||||||
return no_update
|
|
||||||
|
|
||||||
# --- Speichern-Funktion (Demo) ---
|
|
||||||
@callback(
|
|
||||||
Output('save-feedback', 'children'),
|
|
||||||
Input('btn-save', 'n_clicks'),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def save_appointments_demo(n_clicks):
|
|
||||||
if not n_clicks:
|
|
||||||
return no_update
|
|
||||||
return dmc.Alert(
|
|
||||||
"Demo: Termine würden hier gespeichert werden",
|
|
||||||
color="blue",
|
|
||||||
title="Speichern (Demo)"
|
|
||||||
)
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import logging
|
|
||||||
from math import fabs
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
# dashboard/callbacks/appointments_callbacks.py
|
|
||||||
import requests
|
|
||||||
import json
|
|
||||||
from flask import session
|
|
||||||
from dash import Input, Output, State, callback, ctx, dash, no_update
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
# This message will now appear in the terminal during startup
|
|
||||||
logger.debug("Registering appointments page...")
|
|
||||||
|
|
||||||
|
|
||||||
# --- Modalbox öffnen: jetzt auch auf Kalenderklick reagieren ---
|
|
||||||
|
|
||||||
sys.path.append('/workspace')
|
|
||||||
|
|
||||||
print("appointments_callbacks.py geladen")
|
|
||||||
|
|
||||||
API_BASE_URL = os.getenv("API_BASE_URL", "http://192.168.43.100")
|
|
||||||
ENV = os.getenv("ENV", "development")
|
|
||||||
|
|
||||||
|
|
||||||
@callback(
|
|
||||||
dash.Output('output', 'children'),
|
|
||||||
dash.Input('calendar', 'lastDateClick')
|
|
||||||
)
|
|
||||||
def display_date(date_str):
|
|
||||||
if date_str:
|
|
||||||
return f"Letzter Klick auf: {date_str}"
|
|
||||||
return "Klicke auf ein Datum im Kalender!"
|
|
||||||
|
|
||||||
|
|
||||||
@callback(
|
|
||||||
dash.Output('event-output', 'children'),
|
|
||||||
dash.Input('calendar', 'lastEventClick')
|
|
||||||
)
|
|
||||||
def display_event(event_id):
|
|
||||||
if event_id:
|
|
||||||
return f"Letztes Event geklickt: {event_id}"
|
|
||||||
return "Klicke auf ein Event im Kalender!"
|
|
||||||
|
|
||||||
|
|
||||||
@callback(
|
|
||||||
dash.Output('select-output', 'children'),
|
|
||||||
dash.Input('calendar', 'lastSelect')
|
|
||||||
)
|
|
||||||
def display_select(select_info):
|
|
||||||
if select_info:
|
|
||||||
return f"Markiert: {select_info['start']} bis {select_info['end']} (ganztägig: {select_info['allDay']})"
|
|
||||||
return "Markiere einen Bereich im Kalender!"
|
|
||||||
|
|
||||||
|
|
||||||
@callback(
|
|
||||||
dash.Output('calendar', 'events'),
|
|
||||||
dash.Input('calendar', 'lastNavClick'),
|
|
||||||
)
|
|
||||||
def load_events(view_dates):
|
|
||||||
logger.info(f"Lade Events für Zeitraum: {view_dates}")
|
|
||||||
if not view_dates or "start" not in view_dates or "end" not in view_dates:
|
|
||||||
return []
|
|
||||||
start = view_dates["start"]
|
|
||||||
end = view_dates["end"]
|
|
||||||
try:
|
|
||||||
verify_ssl = True if ENV == "production" else False
|
|
||||||
resp = requests.get(
|
|
||||||
f"{API_BASE_URL}/api/events",
|
|
||||||
params={"start": start, "end": end},
|
|
||||||
verify=verify_ssl
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
events = resp.json()
|
|
||||||
return events
|
|
||||||
except Exception as e:
|
|
||||||
logger.info(f"Fehler beim Laden der Events: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
# --- Modalbox öffnen ---
|
|
||||||
|
|
||||||
|
|
||||||
@callback(
|
|
||||||
[
|
|
||||||
Output("appointment-modal", "opened"),
|
|
||||||
Output("start-date-input", "value", allow_duplicate=True),
|
|
||||||
Output("time-start", "value", allow_duplicate=True),
|
|
||||||
Output("time-end", "value", allow_duplicate=True),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
Input("calendar", "lastDateClick"),
|
|
||||||
Input("calendar", "lastSelect"),
|
|
||||||
Input("open-appointment-modal-btn", "n_clicks"),
|
|
||||||
Input("close-appointment-modal-btn", "n_clicks"),
|
|
||||||
],
|
|
||||||
State("appointment-modal", "opened"),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def open_modal(date_click, select, open_click, close_click, is_open):
|
|
||||||
trigger = ctx.triggered_id
|
|
||||||
|
|
||||||
# Bereichsauswahl (lastSelect)
|
|
||||||
if trigger == "calendar" and select:
|
|
||||||
try:
|
|
||||||
start_dt = datetime.fromisoformat(select["start"])
|
|
||||||
end_dt = datetime.fromisoformat(select["end"])
|
|
||||||
|
|
||||||
return (
|
|
||||||
True,
|
|
||||||
start_dt.date().isoformat(),
|
|
||||||
start_dt.strftime("%H:%M"),
|
|
||||||
end_dt.strftime("%H:%M"),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print("Fehler beim Parsen von select:", e)
|
|
||||||
return no_update, no_update, no_update, no_update
|
|
||||||
|
|
||||||
# Einzelklick (lastDateClick)
|
|
||||||
if trigger == "calendar" and date_click:
|
|
||||||
try:
|
|
||||||
dt = datetime.fromisoformat(date_click)
|
|
||||||
# Versuche, die Slotlänge aus dem Kalender zu übernehmen (optional)
|
|
||||||
# Hier als Beispiel 30 Minuten aufaddieren, falls keine Endzeit vorhanden
|
|
||||||
end_dt = dt + timedelta(minutes=30)
|
|
||||||
return (
|
|
||||||
True,
|
|
||||||
dt.date().isoformat(),
|
|
||||||
dt.strftime("%H:%M"),
|
|
||||||
end_dt.strftime("%H:%M"),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print("Fehler beim Parsen von date_click:", e)
|
|
||||||
return no_update, no_update, no_update, no_update
|
|
||||||
|
|
||||||
# Modal öffnen per Button
|
|
||||||
if trigger == "open-appointment-modal-btn" and open_click:
|
|
||||||
now = datetime.now()
|
|
||||||
end_dt = now + timedelta(minutes=30)
|
|
||||||
return True, now.date().isoformat(), now.strftime("%H:%M"), end_dt.strftime("%H:%M")
|
|
||||||
|
|
||||||
# Modal schließen
|
|
||||||
if trigger == "close-appointment-modal-btn" and close_click:
|
|
||||||
return False, no_update, no_update, no_update
|
|
||||||
|
|
||||||
return is_open, no_update, no_update, no_update
|
|
||||||
|
|
||||||
|
|
||||||
# @callback(
|
|
||||||
# Output("time-end", "value", allow_duplicate=True),
|
|
||||||
# Input("time-start", "value"),
|
|
||||||
# prevent_initial_call=True
|
|
||||||
# )
|
|
||||||
# def handle_end_time(start_time, duration="00:30"):
|
|
||||||
# trigger = ctx.triggered_id
|
|
||||||
# if trigger == "time-start" and start_time and duration:
|
|
||||||
# try:
|
|
||||||
# # Beispiel für start_time: "09:00"
|
|
||||||
# start_dt = datetime.strptime(start_time, "%H:%M")
|
|
||||||
# # Dauer in Stunden und Minuten, z.B. "01:30"
|
|
||||||
# hours, minutes = map(int, duration.split(":"))
|
|
||||||
# # Endzeit berechnen: Dauer addieren!
|
|
||||||
# end_dt = start_dt + timedelta(hours=hours, minutes=minutes)
|
|
||||||
# return end_dt.strftime("%H:%M")
|
|
||||||
# except Exception as e:
|
|
||||||
# print("Fehler bei der Berechnung der Endzeit:", e)
|
|
||||||
# return no_update
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# dashboard/callbacks/auth_callbacks.py
|
|
||||||
import dash
|
|
||||||
from dash import Input, Output, State, dcc
|
|
||||||
from flask import session
|
|
||||||
from utils.auth import check_password, get_user_role
|
|
||||||
from config import ENV
|
|
||||||
from utils.db import execute_query
|
|
||||||
|
|
||||||
@dash.callback(
|
|
||||||
Output("login-feedback", "children"),
|
|
||||||
Output("header-right", "children"),
|
|
||||||
Input("btn-login", "n_clicks"),
|
|
||||||
State("input-user", "value"),
|
|
||||||
State("input-pass", "value"),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def login_user(n_clicks, username, password):
|
|
||||||
if ENV == "development":
|
|
||||||
# Dev‐Bypass: setze immer Admin‐Session und leite weiter
|
|
||||||
session["username"] = "dev_admin"
|
|
||||||
session["role"] = "admin"
|
|
||||||
return dcc.Location(href="/overview", id="redirect-dev"), None
|
|
||||||
|
|
||||||
# Produktions‐Login: User in DB suchen
|
|
||||||
user = execute_query("SELECT username, pwd_hash, role FROM users WHERE username=%s", (username,), fetch_one=True)
|
|
||||||
if user and check_password(password, user["pwd_hash"]):
|
|
||||||
session["username"] = user["username"]
|
|
||||||
session["role"] = user["role"]
|
|
||||||
return dcc.Location(href="/overview", id="redirect-ok"), None
|
|
||||||
else:
|
|
||||||
return "Ungültige Zugangsdaten.", None
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
# dashboard/callbacks/overview_callbacks.py
|
|
||||||
import sys
|
|
||||||
sys.path.append('/workspace')
|
|
||||||
import threading
|
|
||||||
import dash
|
|
||||||
import requests
|
|
||||||
from dash import Input, Output, State, MATCH, html, dcc
|
|
||||||
from flask import session
|
|
||||||
from utils.db import get_session # Diese Funktion muss eine SQLAlchemy-Session liefern!
|
|
||||||
from utils.mqtt_client import publish, start_loop
|
|
||||||
from config import ENV
|
|
||||||
import dash_bootstrap_components as dbc
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
import pytz
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
print("overview_callbacks.py geladen")
|
|
||||||
|
|
||||||
API_BASE_URL = os.getenv("API_BASE_URL", "https://192.168.43.100")
|
|
||||||
|
|
||||||
mqtt_thread_started = False
|
|
||||||
SCREENSHOT_DIR = "received-screenshots"
|
|
||||||
|
|
||||||
def ensure_mqtt_running():
|
|
||||||
global mqtt_thread_started
|
|
||||||
if not mqtt_thread_started:
|
|
||||||
thread = threading.Thread(target=start_loop, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
mqtt_thread_started = True
|
|
||||||
|
|
||||||
def get_latest_screenshot(client_uuid):
|
|
||||||
cache_buster = int(time.time()) # aktuelle Unix-Zeit in Sekunden
|
|
||||||
# TODO: Hier genau im Produkitv-Modus die IPs testen!
|
|
||||||
# Wenn API_BASE_URL auf "http" beginnt, absolute URL verwenden (z.B. im lokalen Dev)
|
|
||||||
if API_BASE_URL.startswith("http"):
|
|
||||||
return f"{API_BASE_URL}/screenshots/{client_uuid}?t={cache_buster}"
|
|
||||||
# Sonst relative URL (nginx-Proxy übernimmt das Routing)
|
|
||||||
return f"/screenshots/{client_uuid}?t={cache_buster}"
|
|
||||||
|
|
||||||
def fetch_clients():
|
|
||||||
try:
|
|
||||||
verify_ssl = True if ENV == "production" else False
|
|
||||||
resp = requests.get(
|
|
||||||
f"{API_BASE_URL}/api/clients",
|
|
||||||
verify=verify_ssl
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
except Exception as e:
|
|
||||||
print("Fehler beim Abrufen der Clients:", e)
|
|
||||||
return []
|
|
||||||
|
|
||||||
@dash.callback(
|
|
||||||
Output("clients-cards-container", "children"),
|
|
||||||
Input("interval-update", "n_intervals")
|
|
||||||
)
|
|
||||||
def update_clients(n):
|
|
||||||
# ... Session-Handling wie gehabt ...
|
|
||||||
ensure_mqtt_running()
|
|
||||||
clients = fetch_clients()
|
|
||||||
cards = []
|
|
||||||
for client in clients:
|
|
||||||
uuid = client["uuid"]
|
|
||||||
screenshot = get_latest_screenshot(uuid)
|
|
||||||
last_alive_utc = client.get("last_alive")
|
|
||||||
if last_alive_utc:
|
|
||||||
try:
|
|
||||||
# Unterstützt sowohl "2024-06-08T12:34:56Z" als auch "2024-06-08T12:34:56"
|
|
||||||
if last_alive_utc.endswith("Z"):
|
|
||||||
dt_utc = datetime.strptime(last_alive_utc, "%Y-%m-%dT%H:%M:%SZ")
|
|
||||||
else:
|
|
||||||
dt_utc = datetime.strptime(last_alive_utc, "%Y-%m-%dT%H:%M:%S")
|
|
||||||
dt_utc = dt_utc.replace(tzinfo=pytz.UTC)
|
|
||||||
# Lokale Zeitzone fest angeben, z.B. Europe/Berlin
|
|
||||||
local_tz = pytz.timezone("Europe/Berlin")
|
|
||||||
dt_local = dt_utc.astimezone(local_tz)
|
|
||||||
last_alive_str = dt_local.strftime("%d.%m.%Y %H:%M:%S")
|
|
||||||
except Exception:
|
|
||||||
last_alive_str = last_alive_utc
|
|
||||||
else:
|
|
||||||
last_alive_str = "-"
|
|
||||||
|
|
||||||
card = dbc.Card(
|
|
||||||
[
|
|
||||||
dbc.CardHeader(client["location"]),
|
|
||||||
dbc.CardBody([
|
|
||||||
html.Img(
|
|
||||||
src=screenshot,
|
|
||||||
style={
|
|
||||||
"width": "240px",
|
|
||||||
"height": "135px",
|
|
||||||
"object-fit": "cover",
|
|
||||||
"display": "block",
|
|
||||||
"margin-left": "auto",
|
|
||||||
"margin-right": "auto"
|
|
||||||
},
|
|
||||||
),
|
|
||||||
html.P(f"IP: {client['ip_address'] or '-'}", className="card-text"),
|
|
||||||
html.P(f"Letzte Aktivität: {last_alive_str}", className="card-text"),
|
|
||||||
dbc.ButtonGroup([
|
|
||||||
dbc.Button("Reload Page", color="primary", id={"type": "btn-reload", "index": uuid}, n_clicks=0),
|
|
||||||
dbc.Button("Restart Client", color="danger", id={"type": "btn-restart", "index": uuid}, n_clicks=0),
|
|
||||||
], className="mt-2"),
|
|
||||||
html.Div(id={"type": "restart-feedback", "index": uuid}),
|
|
||||||
html.Div(id={"type": "reload-feedback", "index": uuid}),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
className="mb-4",
|
|
||||||
style={"width": "18rem"},
|
|
||||||
)
|
|
||||||
cards.append(dbc.Col(card, width=4))
|
|
||||||
return dbc.Row(cards)
|
|
||||||
|
|
||||||
@dash.callback(
|
|
||||||
Output({"type": "restart-feedback", "index": MATCH}, "children"),
|
|
||||||
Input({"type": "btn-restart", "index": MATCH}, "n_clicks"),
|
|
||||||
State({"type": "btn-restart", "index": MATCH}, "id")
|
|
||||||
)
|
|
||||||
def on_restart(n_clicks, btn_id):
|
|
||||||
if n_clicks and n_clicks > 0:
|
|
||||||
cid = btn_id["index"]
|
|
||||||
payload = '{"command": "restart"}'
|
|
||||||
ok = publish(f"clients/{cid}/control", payload)
|
|
||||||
return "Befehl gesendet." if ok else "Fehler beim Senden."
|
|
||||||
return ""
|
|
||||||
|
|
||||||
@dash.callback(
|
|
||||||
Output({"type": "reload-feedback", "index": MATCH}, "children"),
|
|
||||||
Input({"type": "btn-reload", "index": MATCH}, "n_clicks"),
|
|
||||||
State({"type": "btn-reload", "index": MATCH}, "id")
|
|
||||||
)
|
|
||||||
def on_reload(n_clicks, btn_id):
|
|
||||||
if n_clicks and n_clicks > 0:
|
|
||||||
cid = btn_id["index"]
|
|
||||||
payload = '{"command": "reload"}'
|
|
||||||
ok = publish(f"clients/{cid}/control", payload)
|
|
||||||
return "Befehl gesendet." if ok else "Fehler beim Senden."
|
|
||||||
return ""
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# dashboard/callbacks/settings_callbacks.py
|
|
||||||
import dash
|
|
||||||
from dash import Input, Output, State, dcc
|
|
||||||
from flask import session
|
|
||||||
from utils.db import execute_query, execute_non_query
|
|
||||||
|
|
||||||
@dash.callback(
|
|
||||||
Output("settings-feedback", "children"),
|
|
||||||
Input("btn-save-settings", "n_clicks"),
|
|
||||||
State("input-default-volume", "value"),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def save_settings(n_clicks, volume):
|
|
||||||
if "role" not in session:
|
|
||||||
return dcc.Location(href="/login")
|
|
||||||
if n_clicks and n_clicks > 0:
|
|
||||||
sql = "UPDATE global_settings SET value=%s WHERE key='default_volume'"
|
|
||||||
rc = execute_non_query(sql, (volume,))
|
|
||||||
return "Einstellungen gespeichert." if rc else "Speichern fehlgeschlagen."
|
|
||||||
return ""
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# dashboard/callbacks/ui_callbacks.py
|
|
||||||
|
|
||||||
from dash import Input, Output, State, callback
|
|
||||||
from components.sidebar import Sidebar
|
|
||||||
|
|
||||||
@callback(
|
|
||||||
Output("sidebar", "children"),
|
|
||||||
Output("sidebar", "className"),
|
|
||||||
Input("sidebar-state", "data"),
|
|
||||||
)
|
|
||||||
def render_sidebar(data):
|
|
||||||
collapsed = data.get("collapsed", False)
|
|
||||||
return Sidebar(collapsed=collapsed), f"sidebar{' collapsed' if collapsed else ''}"
|
|
||||||
|
|
||||||
@callback(
|
|
||||||
Output("sidebar-state", "data"),
|
|
||||||
Input("btn-toggle-sidebar", "n_clicks"),
|
|
||||||
State("sidebar-state", "data"),
|
|
||||||
prevent_initial_call=True,
|
|
||||||
)
|
|
||||||
def toggle_sidebar(n, data):
|
|
||||||
if n is None:
|
|
||||||
# Kein Klick, nichts ändern!
|
|
||||||
return data
|
|
||||||
collapsed = not data.get("collapsed", False)
|
|
||||||
return {"collapsed": collapsed}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# dashboard/callbacks/users_callbacks.py
|
|
||||||
import dash
|
|
||||||
from dash import Input, Output, State, dcc
|
|
||||||
from flask import session
|
|
||||||
from utils.db import execute_query, execute_non_query
|
|
||||||
from utils.auth import hash_password
|
|
||||||
|
|
||||||
@dash.callback(
|
|
||||||
Output("users-feedback", "children"),
|
|
||||||
Input("btn-new-user", "n_clicks"),
|
|
||||||
State("input-new-username", "value"),
|
|
||||||
State("input-new-password", "value"),
|
|
||||||
State("input-new-role", "value"),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def create_user(n_clicks, uname, pwd, role):
|
|
||||||
if session.get("role") != "admin":
|
|
||||||
return "Keine Berechtigung."
|
|
||||||
if n_clicks and n_clicks > 0:
|
|
||||||
pwd_hash = hash_password(pwd)
|
|
||||||
sql = "INSERT INTO users (username, pwd_hash, role) VALUES (%s, %s, %s)"
|
|
||||||
rc = execute_non_query(sql, (uname, pwd_hash, role))
|
|
||||||
return "Benutzer erstellt." if rc else "Fehler beim Erstellen."
|
|
||||||
return ""
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
from dash import html, dcc
|
|
||||||
import dash_mantine_components as dmc
|
|
||||||
from dash_iconify import DashIconify
|
|
||||||
import dash_quill
|
|
||||||
|
|
||||||
def create_input_with_tooltip_full(component, tooltip_text):
|
|
||||||
return dmc.Stack([
|
|
||||||
dmc.Group([
|
|
||||||
component,
|
|
||||||
dmc.Tooltip(
|
|
||||||
children=[
|
|
||||||
dmc.ActionIcon(
|
|
||||||
DashIconify(icon="mdi:help-circle-outline", width=16),
|
|
||||||
variant="subtle",
|
|
||||||
color="gray",
|
|
||||||
size="sm"
|
|
||||||
)
|
|
||||||
],
|
|
||||||
label=tooltip_text,
|
|
||||||
position="top",
|
|
||||||
multiline=True,
|
|
||||||
w=300
|
|
||||||
)
|
|
||||||
], gap="xs", align="flex-end")
|
|
||||||
], gap=0)
|
|
||||||
|
|
||||||
def create_input_with_tooltip_time(component, tooltip_text):
|
|
||||||
return dmc.Group([
|
|
||||||
component,
|
|
||||||
dmc.Tooltip(
|
|
||||||
children=[
|
|
||||||
dmc.ActionIcon(
|
|
||||||
DashIconify(icon="mdi:help-circle-outline", width=16),
|
|
||||||
variant="subtle",
|
|
||||||
color="gray",
|
|
||||||
size="sm"
|
|
||||||
)
|
|
||||||
],
|
|
||||||
label=tooltip_text,
|
|
||||||
position="top",
|
|
||||||
multiline=True,
|
|
||||||
w=300
|
|
||||||
)
|
|
||||||
], gap="xs", align="flex-end")
|
|
||||||
|
|
||||||
def get_appointment_modal():
|
|
||||||
weekday_options = [
|
|
||||||
{"value": "0", "label": "Montag"},
|
|
||||||
{"value": "1", "label": "Dienstag"},
|
|
||||||
{"value": "2", "label": "Mittwoch"},
|
|
||||||
{"value": "3", "label": "Donnerstag"},
|
|
||||||
{"value": "4", "label": "Freitag"},
|
|
||||||
{"value": "5", "label": "Samstag"},
|
|
||||||
{"value": "6", "label": "Sonntag"}
|
|
||||||
]
|
|
||||||
time_options = [
|
|
||||||
{"value": f"{h:02d}:{m:02d}", "label": f"{h:02d}:{m:02d}"}
|
|
||||||
for h in range(6, 24) for m in [0, 30]
|
|
||||||
]
|
|
||||||
return dmc.Modal(
|
|
||||||
id="appointment-modal",
|
|
||||||
title="Neuen Termin anlegen",
|
|
||||||
centered=True,
|
|
||||||
size="auto", # oder "80vw"
|
|
||||||
children=[
|
|
||||||
dmc.Container([
|
|
||||||
dmc.Grid([
|
|
||||||
dmc.GridCol([
|
|
||||||
dmc.Paper([
|
|
||||||
dmc.Title("Termindetails", order=3, className="mb-3"),
|
|
||||||
dmc.Stack([
|
|
||||||
create_input_with_tooltip_full(
|
|
||||||
dmc.TextInput(
|
|
||||||
label="Titel",
|
|
||||||
placeholder="Terminbezeichnung eingeben",
|
|
||||||
leftSection=DashIconify(icon="mdi:calendar-text"),
|
|
||||||
id="title-input",
|
|
||||||
required=True,
|
|
||||||
style={"flex": 1}
|
|
||||||
),
|
|
||||||
"Geben Sie eine aussagekräftige Bezeichnung für den Termin ein"
|
|
||||||
),
|
|
||||||
create_input_with_tooltip_full(
|
|
||||||
dmc.DatePickerInput(
|
|
||||||
label="Startdatum",
|
|
||||||
id="start-date-input",
|
|
||||||
firstDayOfWeek=1,
|
|
||||||
weekendDays=[0, 6],
|
|
||||||
valueFormat="DD.MM.YYYY",
|
|
||||||
placeholder="Datum auswählen",
|
|
||||||
leftSection=DashIconify(icon="mdi:calendar"),
|
|
||||||
clearable=False,
|
|
||||||
style={"flex": 1}
|
|
||||||
),
|
|
||||||
"Wählen Sie das Datum für den Termin aus dem Kalender"
|
|
||||||
),
|
|
||||||
dmc.Grid([
|
|
||||||
dmc.GridCol([
|
|
||||||
dmc.Stack([
|
|
||||||
create_input_with_tooltip_time(
|
|
||||||
dmc.Select(
|
|
||||||
label="Startzeit",
|
|
||||||
placeholder="Zeit auswählen",
|
|
||||||
data=time_options,
|
|
||||||
searchable=True,
|
|
||||||
clearable=True,
|
|
||||||
id="time-start",
|
|
||||||
value="09:00",
|
|
||||||
required=True,
|
|
||||||
style={"flex": 1}
|
|
||||||
),
|
|
||||||
"Eingabe: 10:25, 10.25, 1025 oder 10 (wird automatisch formatiert)"
|
|
||||||
),
|
|
||||||
html.Div(id="start-time-feedback")
|
|
||||||
], gap="xs")
|
|
||||||
], span=6),
|
|
||||||
dmc.GridCol([
|
|
||||||
dmc.Stack([
|
|
||||||
create_input_with_tooltip_time(
|
|
||||||
dmc.Select(
|
|
||||||
label="Endzeit",
|
|
||||||
placeholder="Zeit auswählen",
|
|
||||||
data=time_options,
|
|
||||||
searchable=True,
|
|
||||||
clearable=True,
|
|
||||||
id="time-end",
|
|
||||||
required=True,
|
|
||||||
style={"flex": 1}
|
|
||||||
),
|
|
||||||
"Endzeit muss nach der Startzeit am selben Tag liegen. Termine können nicht über Mitternacht hinausgehen."
|
|
||||||
),
|
|
||||||
html.Div(id="end-time-feedback")
|
|
||||||
], gap="xs")
|
|
||||||
], span=6)
|
|
||||||
]),
|
|
||||||
create_input_with_tooltip_full(
|
|
||||||
dmc.Select(
|
|
||||||
label="Termintyp",
|
|
||||||
placeholder="Typ auswählen",
|
|
||||||
data=[
|
|
||||||
{"value": "presentation", "label": "Präsentation"},
|
|
||||||
{"value": "website", "label": "Website"},
|
|
||||||
{"value": "video", "label": "Video"},
|
|
||||||
{"value": "message", "label": "Nachricht"},
|
|
||||||
{"value": "webuntis", "label": "WebUntis"},
|
|
||||||
{"value": "other", "label": "Sonstiges"}
|
|
||||||
],
|
|
||||||
id="type-input",
|
|
||||||
required=True,
|
|
||||||
style={"flex": 1}
|
|
||||||
),
|
|
||||||
"Wählen Sie die Art der Präsentation aus."
|
|
||||||
),
|
|
||||||
html.Div(id="type-specific-fields"),
|
|
||||||
create_input_with_tooltip_full(
|
|
||||||
dmc.Textarea(
|
|
||||||
label="Beschreibung",
|
|
||||||
placeholder="Zusätzliche Informationen...",
|
|
||||||
minRows=3,
|
|
||||||
autosize=True,
|
|
||||||
id="description-input",
|
|
||||||
style={"flex": 1}
|
|
||||||
),
|
|
||||||
"Optionale Beschreibung mit weiteren Details zum Termin"
|
|
||||||
)
|
|
||||||
], gap="md")
|
|
||||||
], p="md", shadow="sm")
|
|
||||||
], span=6),
|
|
||||||
dmc.GridCol([
|
|
||||||
dmc.Paper([
|
|
||||||
dmc.Title("Wiederholung", order=3, className="mb-3"),
|
|
||||||
dmc.Stack([
|
|
||||||
create_input_with_tooltip_full(
|
|
||||||
dmc.Checkbox(
|
|
||||||
label="Wiederholender Termin",
|
|
||||||
id="repeat-checkbox",
|
|
||||||
description="Aktivieren für wöchentliche Wiederholung"
|
|
||||||
),
|
|
||||||
"Aktivieren Sie diese Option, um den Termin an mehreren Wochentagen zu wiederholen"
|
|
||||||
),
|
|
||||||
html.Div(id="repeat-options", children=[
|
|
||||||
create_input_with_tooltip_full(
|
|
||||||
dmc.MultiSelect(
|
|
||||||
label="Wochentage",
|
|
||||||
placeholder="Wochentage auswählen",
|
|
||||||
data=weekday_options,
|
|
||||||
id="weekdays-select",
|
|
||||||
description="An welchen Wochentagen soll der Termin stattfinden?",
|
|
||||||
disabled=True,
|
|
||||||
style={"flex": 1}
|
|
||||||
),
|
|
||||||
"Wählen Sie mindestens einen Wochentag für die Wiederholung aus"
|
|
||||||
),
|
|
||||||
dmc.Space(h="md"),
|
|
||||||
create_input_with_tooltip_full(
|
|
||||||
dmc.DatePickerInput(
|
|
||||||
label="Wiederholung bis",
|
|
||||||
id="repeat-until-date",
|
|
||||||
firstDayOfWeek=1,
|
|
||||||
weekendDays=[0, 6],
|
|
||||||
valueFormat="DD.MM.YYYY",
|
|
||||||
placeholder="Enddatum auswählen",
|
|
||||||
leftSection=DashIconify(icon="mdi:calendar-end"),
|
|
||||||
description="Letzter Tag der Wiederholung",
|
|
||||||
disabled=True,
|
|
||||||
clearable=True,
|
|
||||||
style={"flex": 1}
|
|
||||||
),
|
|
||||||
"Datum bis wann die Wiederholung stattfinden soll. Muss nach dem Startdatum liegen."
|
|
||||||
),
|
|
||||||
dmc.Space(h="lg"),
|
|
||||||
create_input_with_tooltip_full(
|
|
||||||
dmc.Checkbox(
|
|
||||||
label="Ferientage berücksichtigen",
|
|
||||||
id="skip-holidays-checkbox",
|
|
||||||
description="Termine an Feiertagen und in Schulferien auslassen",
|
|
||||||
disabled=True
|
|
||||||
),
|
|
||||||
"Aktivieren Sie diese Option, um Termine an deutschen Feiertagen und in Schulferien automatisch zu überspringen"
|
|
||||||
)
|
|
||||||
])
|
|
||||||
], gap="md")
|
|
||||||
], p="md", shadow="sm"),
|
|
||||||
dmc.Paper([
|
|
||||||
dmc.Title("Aktionen", order=3, className="mb-3"),
|
|
||||||
dmc.Stack([
|
|
||||||
dmc.Button(
|
|
||||||
"Termin(e) speichern",
|
|
||||||
color="green",
|
|
||||||
leftSection=DashIconify(icon="mdi:content-save"),
|
|
||||||
id="btn-save",
|
|
||||||
size="lg",
|
|
||||||
fullWidth=True
|
|
||||||
),
|
|
||||||
dmc.Button(
|
|
||||||
"Zurücksetzen",
|
|
||||||
color="gray",
|
|
||||||
variant="outline",
|
|
||||||
leftSection=DashIconify(icon="mdi:refresh"),
|
|
||||||
id="btn-reset",
|
|
||||||
fullWidth=True
|
|
||||||
),
|
|
||||||
dmc.Button(
|
|
||||||
"Schließen",
|
|
||||||
id="close-appointment-modal-btn",
|
|
||||||
color="red", # oder "danger"
|
|
||||||
leftSection=DashIconify(icon="mdi:close"),
|
|
||||||
variant="filled",
|
|
||||||
style={"marginBottom": 10}
|
|
||||||
),
|
|
||||||
html.Div(id="save-feedback", className="mt-3")
|
|
||||||
], gap="md")
|
|
||||||
], p="md", shadow="sm", className="mt-3")
|
|
||||||
], span=6)
|
|
||||||
])
|
|
||||||
], size="lg")
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# dashboard/components/header.py
|
|
||||||
from dash import html
|
|
||||||
|
|
||||||
def Header():
|
|
||||||
return html.Div(
|
|
||||||
className="app-header",
|
|
||||||
children=[
|
|
||||||
html.Img(src="/assets/logo.png", className="logo"),
|
|
||||||
html.Span("Infoscreen-Manager", className="app-title"),
|
|
||||||
html.Span(" – Organisationsname", className="org-name"),
|
|
||||||
html.Div(id="header-right", className="header-right") # Platzhalter für Login/Profil‐Button
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# dashboard/components/sidebar.py
|
|
||||||
|
|
||||||
from dash import html
|
|
||||||
import dash_bootstrap_components as dbc
|
|
||||||
from dash_iconify import DashIconify
|
|
||||||
|
|
||||||
def Sidebar(collapsed: bool = False):
|
|
||||||
"""
|
|
||||||
Gibt nur den Inhalt der Sidebar zurück (ohne das äußere div mit id="sidebar").
|
|
||||||
Das äußere div wird bereits in app.py definiert.
|
|
||||||
"""
|
|
||||||
|
|
||||||
nav_items = [
|
|
||||||
{"label": "Übersicht", "href": "/overview", "icon": "mdi:view-dashboard"},
|
|
||||||
{"label": "Termine", "href": "/appointments","icon": "mdi:calendar"},
|
|
||||||
{"label": "Bildschirme", "href": "/clients", "icon": "mdi:monitor"},
|
|
||||||
{"label": "Einstellungen","href": "/settings", "icon": "mdi:cog"},
|
|
||||||
{"label": "Benutzer", "href": "/users", "icon": "mdi:account"},
|
|
||||||
]
|
|
||||||
|
|
||||||
if collapsed:
|
|
||||||
nav_links = [
|
|
||||||
dbc.NavLink(
|
|
||||||
DashIconify(icon=item["icon"], width=24),
|
|
||||||
href=item["href"],
|
|
||||||
active="exact",
|
|
||||||
className="sidebar-item-collapsed",
|
|
||||||
id={"type": "nav-item", "index": item["label"]},
|
|
||||||
)
|
|
||||||
for item in nav_items
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
nav_links = [
|
|
||||||
dbc.NavLink(
|
|
||||||
[
|
|
||||||
DashIconify(icon=item["icon"], width=24),
|
|
||||||
html.Span(item["label"], className="ms-2 sidebar-label"),
|
|
||||||
],
|
|
||||||
href=item["href"],
|
|
||||||
active="exact",
|
|
||||||
className="sidebar-item",
|
|
||||||
id={"type": "nav-item", "index": item["label"]},
|
|
||||||
)
|
|
||||||
for item in nav_items
|
|
||||||
]
|
|
||||||
|
|
||||||
return [
|
|
||||||
html.Div(
|
|
||||||
className="sidebar-toggle",
|
|
||||||
children=html.Button(
|
|
||||||
DashIconify(icon="mdi:menu", width=28),
|
|
||||||
id="btn-toggle-sidebar",
|
|
||||||
className="toggle-button",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
dbc.Collapse(
|
|
||||||
dbc.Nav(
|
|
||||||
nav_links,
|
|
||||||
vertical=True,
|
|
||||||
pills=True,
|
|
||||||
className="sidebar-nav",
|
|
||||||
),
|
|
||||||
is_open=not collapsed,
|
|
||||||
className="sidebar-nav",
|
|
||||||
) if not collapsed else
|
|
||||||
dbc.Nav(
|
|
||||||
nav_links,
|
|
||||||
vertical=True,
|
|
||||||
pills=True,
|
|
||||||
className="sidebar-nav-collapsed",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# dashboard/config.py
|
|
||||||
import os
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
# .env aus Root‐Verzeichnis laden
|
|
||||||
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
||||||
load_dotenv(os.path.join(base_dir, ".env"))
|
|
||||||
|
|
||||||
# DB‐Einstellungen
|
|
||||||
DB_HOST = os.getenv("DB_HOST")
|
|
||||||
DB_PORT = int(os.getenv("DB_PORT", "3306"))
|
|
||||||
DB_USER = os.getenv("DB_USER")
|
|
||||||
DB_PASSWORD = os.getenv("DB_PASSWORD")
|
|
||||||
DB_NAME = os.getenv("DB_NAME")
|
|
||||||
DB_POOL_NAME = os.getenv("DB_POOL_NAME", "my_pool")
|
|
||||||
DB_POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "5"))
|
|
||||||
|
|
||||||
# MQTT‐Einstellungen
|
|
||||||
MQTT_BROKER_HOST = os.getenv("MQTT_BROKER_HOST")
|
|
||||||
MQTT_BROKER_PORT = int(os.getenv("MQTT_BROKER_PORT", "1883"))
|
|
||||||
MQTT_USERNAME = os.getenv("MQTT_USERNAME")
|
|
||||||
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD")
|
|
||||||
MQTT_KEEPALIVE = int(os.getenv("MQTT_KEEPALIVE", "60"))
|
|
||||||
MQTT_CLIENT_ID = os.getenv("MQTT_CLIENT_ID")
|
|
||||||
|
|
||||||
# Sonstige Einstellungen
|
|
||||||
SECRET_KEY = os.getenv("SECRET_KEY", "changeme")
|
|
||||||
ENV = os.getenv("ENV", "development")
|
|
||||||
Binary file not shown.
@@ -1,402 +0,0 @@
|
|||||||
import dash
|
|
||||||
import full_calendar_component as fcc
|
|
||||||
from dash import *
|
|
||||||
import dash_mantine_components as dmc
|
|
||||||
from dash.exceptions import PreventUpdate
|
|
||||||
from datetime import datetime, date, timedelta
|
|
||||||
import dash_quill
|
|
||||||
|
|
||||||
# dash._dash_renderer._set_react_version('18.2.0')
|
|
||||||
|
|
||||||
app = Dash(__name__, prevent_initial_callbacks=True)
|
|
||||||
|
|
||||||
quill_mods = [
|
|
||||||
[{"header": "1"}, {"header": "2"}, {"font": []}],
|
|
||||||
[{"size": []}],
|
|
||||||
["bold", "italic", "underline", "strike", "blockquote"],
|
|
||||||
[{"list": "ordered"}, {"list": "bullet"}, {"indent": "-1"}, {"indent": "+1"}],
|
|
||||||
["link", "image"],
|
|
||||||
]
|
|
||||||
|
|
||||||
# Get today's date
|
|
||||||
today = datetime.now()
|
|
||||||
|
|
||||||
# Format the date
|
|
||||||
formatted_date = today.strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
app.layout = html.Div(
|
|
||||||
[
|
|
||||||
fcc.FullCalendarComponent(
|
|
||||||
id="calendar", # Unique ID for the component
|
|
||||||
initialView="listWeek", # dayGridMonth, timeGridWeek, timeGridDay, listWeek,
|
|
||||||
# dayGridWeek, dayGridYear, multiMonthYear, resourceTimeline, resourceTimeGridDay, resourceTimeLineWeek
|
|
||||||
headerToolbar={
|
|
||||||
"left": "prev,next today",
|
|
||||||
"center": "",
|
|
||||||
"right": "listWeek,timeGridDay,timeGridWeek,dayGridMonth",
|
|
||||||
}, # Calendar header
|
|
||||||
initialDate=f"{formatted_date}", # Start date for calendar
|
|
||||||
editable=True, # Allow events to be edited
|
|
||||||
selectable=True, # Allow dates to be selected
|
|
||||||
events=[],
|
|
||||||
nowIndicator=True, # Show current time indicator
|
|
||||||
navLinks=True, # Allow navigation to other dates
|
|
||||||
),
|
|
||||||
dmc.MantineProvider(
|
|
||||||
theme={"colorScheme": "dark"},
|
|
||||||
children=[
|
|
||||||
dmc.Modal(
|
|
||||||
id="modal",
|
|
||||||
size="xl",
|
|
||||||
title="Event Details",
|
|
||||||
zIndex=10000,
|
|
||||||
children=[
|
|
||||||
html.Div(id="modal_event_display_context"),
|
|
||||||
dmc.Space(h=20),
|
|
||||||
dmc.Group(
|
|
||||||
[
|
|
||||||
dmc.Button(
|
|
||||||
"Close",
|
|
||||||
color="red",
|
|
||||||
variant="outline",
|
|
||||||
id="modal-close-button",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
pos="right",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
dmc.MantineProvider(
|
|
||||||
theme={"colorScheme": "dark"},
|
|
||||||
children=[
|
|
||||||
dmc.Modal(
|
|
||||||
id="add_modal",
|
|
||||||
title="New Event",
|
|
||||||
size="xl",
|
|
||||||
children=[
|
|
||||||
dmc.Grid(
|
|
||||||
children=[
|
|
||||||
dmc.GridCol(
|
|
||||||
html.Div(
|
|
||||||
dmc.DatePickerInput(
|
|
||||||
id="start_date",
|
|
||||||
label="Start Date",
|
|
||||||
value=datetime.now().date(),
|
|
||||||
styles={"width": "100%"},
|
|
||||||
disabled=True,
|
|
||||||
),
|
|
||||||
style={"width": "100%"},
|
|
||||||
),
|
|
||||||
span=6,
|
|
||||||
),
|
|
||||||
dmc.GridCol(
|
|
||||||
html.Div(
|
|
||||||
dmc.TimeInput(
|
|
||||||
label="Start Time",
|
|
||||||
withSeconds=True,
|
|
||||||
value=datetime.now(),
|
|
||||||
# format="12",
|
|
||||||
id="start_time",
|
|
||||||
),
|
|
||||||
style={"width": "100%"},
|
|
||||||
),
|
|
||||||
span=6,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
gutter="xl",
|
|
||||||
),
|
|
||||||
dmc.Grid(
|
|
||||||
children=[
|
|
||||||
dmc.GridCol(
|
|
||||||
html.Div(
|
|
||||||
dmc.DatePickerInput(
|
|
||||||
id="end_date",
|
|
||||||
label="End Date",
|
|
||||||
value=datetime.now().date(),
|
|
||||||
styles={"width": "100%"},
|
|
||||||
),
|
|
||||||
style={"width": "100%"},
|
|
||||||
),
|
|
||||||
span=6,
|
|
||||||
),
|
|
||||||
dmc.GridCol(
|
|
||||||
html.Div(
|
|
||||||
dmc.TimeInput(
|
|
||||||
label="End Time",
|
|
||||||
withSeconds=True,
|
|
||||||
value=datetime.now(),
|
|
||||||
# format="12",
|
|
||||||
id="end_time",
|
|
||||||
),
|
|
||||||
style={"width": "100%"},
|
|
||||||
),
|
|
||||||
span=6,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
gutter="xl",
|
|
||||||
),
|
|
||||||
dmc.Grid(
|
|
||||||
children=[
|
|
||||||
dmc.GridCol(
|
|
||||||
span=6,
|
|
||||||
children=[
|
|
||||||
dmc.TextInput(
|
|
||||||
label="Event Title:",
|
|
||||||
style={"width": "100%"},
|
|
||||||
id="event_name_input",
|
|
||||||
required=True,
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
dmc.GridCol(
|
|
||||||
span=6,
|
|
||||||
children=[
|
|
||||||
dmc.Select(
|
|
||||||
label="Select event color",
|
|
||||||
placeholder="Select one",
|
|
||||||
id="event_color_select",
|
|
||||||
value="ng",
|
|
||||||
data=[
|
|
||||||
{
|
|
||||||
"value": "bg-gradient-primary",
|
|
||||||
"label": "bg-gradient-primary",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "bg-gradient-secondary",
|
|
||||||
"label": "bg-gradient-secondary",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "bg-gradient-success",
|
|
||||||
"label": "bg-gradient-success",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "bg-gradient-info",
|
|
||||||
"label": "bg-gradient-info",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "bg-gradient-warning",
|
|
||||||
"label": "bg-gradient-warning",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "bg-gradient-danger",
|
|
||||||
"label": "bg-gradient-danger",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "bg-gradient-light",
|
|
||||||
"label": "bg-gradient-light",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "bg-gradient-dark",
|
|
||||||
"label": "bg-gradient-dark",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "bg-gradient-white",
|
|
||||||
"label": "bg-gradient-white",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
style={"width": "100%", "marginBottom": 10},
|
|
||||||
required=True,
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
dash_quill.Quill(
|
|
||||||
id="rich_text_input",
|
|
||||||
modules={
|
|
||||||
"toolbar": quill_mods,
|
|
||||||
"clipboard": {
|
|
||||||
"matchVisual": False,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
dmc.Accordion(
|
|
||||||
children=[
|
|
||||||
dmc.AccordionItem(
|
|
||||||
[
|
|
||||||
dmc.AccordionControl("Raw HTML"),
|
|
||||||
dmc.AccordionPanel(
|
|
||||||
html.Div(
|
|
||||||
id="rich_text_output",
|
|
||||||
style={
|
|
||||||
"height": "300px",
|
|
||||||
"overflowY": "scroll",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
),
|
|
||||||
],
|
|
||||||
value="raw_html",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
dmc.Space(h=20),
|
|
||||||
dmc.Group(
|
|
||||||
[
|
|
||||||
dmc.Button(
|
|
||||||
"Submit",
|
|
||||||
id="modal_submit_new_event_button",
|
|
||||||
color="green",
|
|
||||||
),
|
|
||||||
dmc.Button(
|
|
||||||
"Close",
|
|
||||||
color="red",
|
|
||||||
variant="outline",
|
|
||||||
id="modal_close_new_event_button",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
pos="right",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.callback(
|
|
||||||
Output("modal", "opened"),
|
|
||||||
Output("modal", "title"),
|
|
||||||
Output("modal_event_display_context", "children"),
|
|
||||||
Input("modal-close-button", "n_clicks"),
|
|
||||||
Input("calendar", "clickedEvent"),
|
|
||||||
State("modal", "opened"),
|
|
||||||
)
|
|
||||||
def open_event_modal(n, clickedEvent, opened):
|
|
||||||
ctx = callback_context
|
|
||||||
|
|
||||||
if not ctx.triggered:
|
|
||||||
raise PreventUpdate
|
|
||||||
else:
|
|
||||||
button_id = ctx.triggered[0]["prop_id"].split(".")[0]
|
|
||||||
|
|
||||||
if button_id == "calendar" and clickedEvent is not None:
|
|
||||||
event_title = clickedEvent["title"]
|
|
||||||
event_context = clickedEvent["extendedProps"]["context"]
|
|
||||||
return (
|
|
||||||
True,
|
|
||||||
event_title,
|
|
||||||
html.Div(
|
|
||||||
dash_quill.Quill(
|
|
||||||
id="input3",
|
|
||||||
value=f"{event_context}",
|
|
||||||
modules={
|
|
||||||
"toolbar": False,
|
|
||||||
"clipboard": {
|
|
||||||
"matchVisual": False,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
style={"width": "100%", "overflowY": "auto"},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
elif button_id == "modal-close-button" and n is not None:
|
|
||||||
return False, dash.no_update, dash.no_update
|
|
||||||
|
|
||||||
return opened, dash.no_update
|
|
||||||
|
|
||||||
|
|
||||||
@app.callback(
|
|
||||||
Output("add_modal", "opened"),
|
|
||||||
Output("start_date", "value"),
|
|
||||||
Output("end_date", "value"),
|
|
||||||
Output("start_time", "value"),
|
|
||||||
Output("end_time", "value"),
|
|
||||||
Input("calendar", "dateClicked"),
|
|
||||||
Input("modal_close_new_event_button", "n_clicks"),
|
|
||||||
State("add_modal", "opened"),
|
|
||||||
)
|
|
||||||
def open_add_modal(dateClicked, close_clicks, opened):
|
|
||||||
|
|
||||||
ctx = callback_context
|
|
||||||
|
|
||||||
if not ctx.triggered:
|
|
||||||
raise PreventUpdate
|
|
||||||
else:
|
|
||||||
button_id = ctx.triggered[0]["prop_id"].split(".")[0]
|
|
||||||
|
|
||||||
if button_id == "calendar" and dateClicked is not None:
|
|
||||||
try:
|
|
||||||
start_time = datetime.strptime(dateClicked, "%Y-%m-%dT%H:%M:%S%z").time()
|
|
||||||
start_date_obj = datetime.strptime(dateClicked, "%Y-%m-%dT%H:%M:%S%z")
|
|
||||||
start_date = start_date_obj.strftime("%Y-%m-%d")
|
|
||||||
end_date = start_date_obj.strftime("%Y-%m-%d")
|
|
||||||
except ValueError:
|
|
||||||
start_time = datetime.now().time()
|
|
||||||
start_date_obj = datetime.strptime(dateClicked, "%Y-%m-%d")
|
|
||||||
start_date = start_date_obj.strftime("%Y-%m-%d")
|
|
||||||
end_date = start_date_obj.strftime("%Y-%m-%d")
|
|
||||||
end_time = datetime.combine(date.today(), start_time) + timedelta(hours=1)
|
|
||||||
start_time_str = start_time.strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
end_time_str = end_time.strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
return True, start_date, end_date, start_time_str, end_time_str
|
|
||||||
|
|
||||||
elif button_id == "modal_close_new_event_button" and close_clicks is not None:
|
|
||||||
return False, dash.no_update, dash.no_update, dash.no_update, dash.no_update
|
|
||||||
|
|
||||||
return opened, dash.no_update, dash.no_update, dash.no_update, dash.no_update
|
|
||||||
|
|
||||||
|
|
||||||
@app.callback(
|
|
||||||
Output("calendar", "events"),
|
|
||||||
Output("add_modal", "opened", allow_duplicate=True),
|
|
||||||
Output("event_name_input", "value"),
|
|
||||||
Output("event_color_select", "value"),
|
|
||||||
Output("rich_text_input", "value"),
|
|
||||||
Input("modal_submit_new_event_button", "n_clicks"),
|
|
||||||
State("start_date", "value"),
|
|
||||||
State("start_time", "value"),
|
|
||||||
State("end_date", "value"),
|
|
||||||
State("end_time", "value"),
|
|
||||||
State("event_name_input", "value"),
|
|
||||||
State("event_color_select", "value"),
|
|
||||||
State("rich_text_output", "children"),
|
|
||||||
State("calendar", "events"),
|
|
||||||
)
|
|
||||||
def add_new_event(
|
|
||||||
n,
|
|
||||||
start_date,
|
|
||||||
start_time,
|
|
||||||
end_date,
|
|
||||||
end_time,
|
|
||||||
event_name,
|
|
||||||
event_color,
|
|
||||||
event_context,
|
|
||||||
current_events,
|
|
||||||
):
|
|
||||||
if n is None:
|
|
||||||
raise PreventUpdate
|
|
||||||
|
|
||||||
start_time_obj = datetime.strptime(start_time, "%Y-%m-%d %H:%M:%S")
|
|
||||||
end_time_obj = datetime.strptime(end_time, "%Y-%m-%d %H:%M:%S")
|
|
||||||
|
|
||||||
start_time_str = start_time_obj.strftime("%H:%M:%S")
|
|
||||||
end_time_str = end_time_obj.strftime("%H:%M:%S")
|
|
||||||
|
|
||||||
start_date = f"{start_date}T{start_time_str}"
|
|
||||||
end_date = f"{end_date}T{end_time_str}"
|
|
||||||
|
|
||||||
new_event = {
|
|
||||||
"title": event_name,
|
|
||||||
"start": start_date,
|
|
||||||
"end": end_date,
|
|
||||||
"className": event_color,
|
|
||||||
"context": event_context,
|
|
||||||
}
|
|
||||||
|
|
||||||
return current_events + [new_event], False, "", "bg-gradient-primary", ""
|
|
||||||
|
|
||||||
|
|
||||||
@app.callback(
|
|
||||||
Output("rich_text_output", "children"),
|
|
||||||
[Input("rich_text_input", "value")],
|
|
||||||
[State("rich_text_input", "charCount")],
|
|
||||||
)
|
|
||||||
def display_output(value, charCount):
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app.run(host='0.0.0.0', debug=True, port=8050)
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
"""
|
|
||||||
Collapsible navbar on both desktop and mobile
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
import dash_mantine_components as dmc
|
|
||||||
from dash import Dash, Input, Output, State, callback
|
|
||||||
from dash_iconify import DashIconify
|
|
||||||
|
|
||||||
app = Dash(external_stylesheets=dmc.styles.ALL)
|
|
||||||
|
|
||||||
logo = "https://github.com/user-attachments/assets/c1ff143b-4365-4fd1-880f-3e97aab5c302"
|
|
||||||
|
|
||||||
def get_icon(icon):
|
|
||||||
return DashIconify(icon=icon, height=16)
|
|
||||||
|
|
||||||
layout = dmc.AppShell(
|
|
||||||
[
|
|
||||||
dmc.AppShellHeader(
|
|
||||||
dmc.Group(
|
|
||||||
[
|
|
||||||
dmc.Burger(
|
|
||||||
id="mobile-burger",
|
|
||||||
size="sm",
|
|
||||||
hiddenFrom="sm",
|
|
||||||
opened=False,
|
|
||||||
),
|
|
||||||
dmc.Burger(
|
|
||||||
id="desktop-burger",
|
|
||||||
size="sm",
|
|
||||||
visibleFrom="sm",
|
|
||||||
opened=True,
|
|
||||||
),
|
|
||||||
dmc.Image(src=logo, h=40),
|
|
||||||
dmc.Title("Demo App", c="blue"),
|
|
||||||
],
|
|
||||||
h="100%",
|
|
||||||
px="md",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
dmc.AppShellNavbar(
|
|
||||||
id="navbar",
|
|
||||||
children=[
|
|
||||||
"Navbar",
|
|
||||||
dmc.NavLink(
|
|
||||||
label="With icon",
|
|
||||||
leftSection=get_icon(icon="bi:house-door-fill"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
p="md",
|
|
||||||
),
|
|
||||||
dmc.AppShellMain("Main"),
|
|
||||||
],
|
|
||||||
header={"height": 60},
|
|
||||||
navbar={
|
|
||||||
"width": 300,
|
|
||||||
"breakpoint": "sm",
|
|
||||||
"collapsed": {"mobile": True, "desktop": False},
|
|
||||||
},
|
|
||||||
padding="md",
|
|
||||||
id="appshell",
|
|
||||||
)
|
|
||||||
|
|
||||||
app.layout = dmc.MantineProvider(layout)
|
|
||||||
|
|
||||||
|
|
||||||
@callback(
|
|
||||||
Output("appshell", "navbar"),
|
|
||||||
Input("mobile-burger", "opened"),
|
|
||||||
Input("desktop-burger", "opened"),
|
|
||||||
State("appshell", "navbar"),
|
|
||||||
)
|
|
||||||
def toggle_navbar(mobile_opened, desktop_opened, navbar):
|
|
||||||
navbar["collapsed"] = {
|
|
||||||
"mobile": not mobile_opened,
|
|
||||||
"desktop": not desktop_opened,
|
|
||||||
}
|
|
||||||
return navbar
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app.run(host='0.0.0.0', debug=True, port=8050)
|
|
||||||
@@ -1,892 +0,0 @@
|
|||||||
import dash
|
|
||||||
from dash import html, Input, Output, State, callback, dcc
|
|
||||||
import dash_mantine_components as dmc
|
|
||||||
from dash_iconify import DashIconify
|
|
||||||
from datetime import datetime, date, timedelta
|
|
||||||
import re
|
|
||||||
import base64
|
|
||||||
import dash_quill
|
|
||||||
|
|
||||||
app = dash.Dash(__name__, suppress_callback_exceptions=True) # Wichtig für dynamische IDs
|
|
||||||
|
|
||||||
# Deutsche Lokalisierung für Mantine
|
|
||||||
german_dates_provider_props = {
|
|
||||||
"settings": {
|
|
||||||
"locale": "de",
|
|
||||||
"firstDayOfWeek": 1,
|
|
||||||
"weekendDays": [0, 6],
|
|
||||||
"labels": {
|
|
||||||
"ok": "OK",
|
|
||||||
"cancel": "Abbrechen",
|
|
||||||
"clear": "Löschen",
|
|
||||||
"monthPickerControl": "Monat auswählen",
|
|
||||||
"yearPickerControl": "Jahr auswählen",
|
|
||||||
"nextMonth": "Nächster Monat",
|
|
||||||
"previousMonth": "Vorheriger Monat",
|
|
||||||
"nextYear": "Nächstes Jahr",
|
|
||||||
"previousYear": "Vorheriges Jahr",
|
|
||||||
"nextDecade": "Nächstes Jahrzehnt",
|
|
||||||
"previousDecade": "Vorheriges Jahrzehnt"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Wochentage für Wiederholung
|
|
||||||
weekday_options = [
|
|
||||||
{"value": "0", "label": "Montag"},
|
|
||||||
{"value": "1", "label": "Dienstag"},
|
|
||||||
{"value": "2", "label": "Mittwoch"},
|
|
||||||
{"value": "3", "label": "Donnerstag"},
|
|
||||||
{"value": "4", "label": "Freitag"},
|
|
||||||
{"value": "5", "label": "Samstag"},
|
|
||||||
{"value": "6", "label": "Sonntag"}
|
|
||||||
]
|
|
||||||
|
|
||||||
# Deutsche Feiertage (vereinfacht, ohne Berechnung von Ostern etc.)
|
|
||||||
GERMAN_HOLIDAYS_2025 = [
|
|
||||||
date(2025, 1, 1), # Neujahr
|
|
||||||
date(2025, 1, 6), # Heilige Drei Könige
|
|
||||||
date(2025, 4, 18), # Karfreitag (Beispiel - muss berechnet werden)
|
|
||||||
date(2025, 4, 21), # Ostermontag (Beispiel - muss berechnet werden)
|
|
||||||
date(2025, 5, 1), # Tag der Arbeit
|
|
||||||
date(2025, 5, 29), # Christi Himmelfahrt (Beispiel - muss berechnet werden)
|
|
||||||
date(2025, 6, 9), # Pfingstmontag (Beispiel - muss berechnet werden)
|
|
||||||
date(2025, 10, 3), # Tag der Deutschen Einheit
|
|
||||||
date(2025, 12, 25), # 1. Weihnachtstag
|
|
||||||
date(2025, 12, 26), # 2. Weihnachtstag
|
|
||||||
]
|
|
||||||
|
|
||||||
# Schulferien (Beispiel für NRW 2025)
|
|
||||||
SCHOOL_HOLIDAYS_2025 = [
|
|
||||||
# Weihnachtsferien
|
|
||||||
(date(2024, 12, 23), date(2025, 1, 6)),
|
|
||||||
# Osterferien
|
|
||||||
(date(2025, 4, 14), date(2025, 4, 26)),
|
|
||||||
# Sommerferien
|
|
||||||
(date(2025, 7, 14), date(2025, 8, 26)),
|
|
||||||
# Herbstferien
|
|
||||||
(date(2025, 10, 14), date(2025, 10, 25)),
|
|
||||||
]
|
|
||||||
|
|
||||||
def is_holiday_or_vacation(check_date):
|
|
||||||
"""Prüft, ob ein Datum ein Feiertag oder in den Ferien liegt"""
|
|
||||||
# Feiertage prüfen
|
|
||||||
if check_date in GERMAN_HOLIDAYS_2025:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Schulferien prüfen
|
|
||||||
for start_vacation, end_vacation in SCHOOL_HOLIDAYS_2025:
|
|
||||||
if start_vacation <= check_date <= end_vacation:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Zeitraster für 30-Minuten-Intervalle generieren
|
|
||||||
def generate_time_options():
|
|
||||||
options = []
|
|
||||||
|
|
||||||
# Basis: 30-Minuten-Raster
|
|
||||||
for h in range(6, 24): # Bis 23:30
|
|
||||||
for m in [0, 30]:
|
|
||||||
options.append({"value": f"{h:02d}:{m:02d}", "label": f"{h:02d}:{m:02d}"})
|
|
||||||
|
|
||||||
return options
|
|
||||||
|
|
||||||
time_options = generate_time_options()
|
|
||||||
|
|
||||||
# Hilfsfunktion für Input-Felder mit Tooltip - volle Breite
|
|
||||||
def create_input_with_tooltip_full(component, tooltip_text):
|
|
||||||
"""Erstellt ein Input-Feld mit Tooltip-Icon über die volle Breite"""
|
|
||||||
return dmc.Stack([
|
|
||||||
dmc.Group([
|
|
||||||
component,
|
|
||||||
dmc.Tooltip(
|
|
||||||
children=[
|
|
||||||
dmc.ActionIcon(
|
|
||||||
DashIconify(icon="mdi:help-circle-outline", width=16),
|
|
||||||
variant="subtle",
|
|
||||||
color="gray",
|
|
||||||
size="sm"
|
|
||||||
)
|
|
||||||
],
|
|
||||||
label=tooltip_text,
|
|
||||||
position="top",
|
|
||||||
multiline=True,
|
|
||||||
w=300
|
|
||||||
)
|
|
||||||
], gap="xs", align="flex-end")
|
|
||||||
], gap=0)
|
|
||||||
|
|
||||||
# Hilfsfunktion für Input-Felder mit Tooltip - für Zeit-Grid
|
|
||||||
def create_input_with_tooltip_time(component, tooltip_text):
|
|
||||||
"""Erstellt ein Input-Feld mit Tooltip-Icon für Zeit-Eingaben"""
|
|
||||||
return dmc.Group([
|
|
||||||
component,
|
|
||||||
dmc.Tooltip(
|
|
||||||
children=[
|
|
||||||
dmc.ActionIcon(
|
|
||||||
DashIconify(icon="mdi:help-circle-outline", width=16),
|
|
||||||
variant="subtle",
|
|
||||||
color="gray",
|
|
||||||
size="sm"
|
|
||||||
)
|
|
||||||
],
|
|
||||||
label=tooltip_text,
|
|
||||||
position="top",
|
|
||||||
multiline=True,
|
|
||||||
w=300
|
|
||||||
)
|
|
||||||
], gap="xs", align="flex-end")
|
|
||||||
|
|
||||||
app.layout = dmc.MantineProvider([
|
|
||||||
dmc.DatesProvider(**german_dates_provider_props, children=[
|
|
||||||
dmc.Container([
|
|
||||||
dmc.Title("Erweiterte Terminverwaltung", order=1, className="mb-4"),
|
|
||||||
|
|
||||||
dmc.Grid([
|
|
||||||
dmc.GridCol([
|
|
||||||
dmc.Paper([
|
|
||||||
dmc.Title("Termindetails", order=3, className="mb-3"),
|
|
||||||
|
|
||||||
dmc.Stack([
|
|
||||||
create_input_with_tooltip_full(
|
|
||||||
dmc.TextInput(
|
|
||||||
label="Titel",
|
|
||||||
placeholder="Terminbezeichnung eingeben",
|
|
||||||
leftSection=DashIconify(icon="mdi:calendar-text"),
|
|
||||||
id="title-input",
|
|
||||||
required=True,
|
|
||||||
style={"flex": 1}
|
|
||||||
),
|
|
||||||
"Geben Sie eine aussagekräftige Bezeichnung für den Termin ein"
|
|
||||||
),
|
|
||||||
|
|
||||||
create_input_with_tooltip_full(
|
|
||||||
dmc.DatePickerInput(
|
|
||||||
label="Startdatum",
|
|
||||||
value=datetime.now().date(),
|
|
||||||
id="start-date-input",
|
|
||||||
firstDayOfWeek=1,
|
|
||||||
weekendDays=[0, 6],
|
|
||||||
valueFormat="DD.MM.YYYY",
|
|
||||||
placeholder="Datum auswählen",
|
|
||||||
leftSection=DashIconify(icon="mdi:calendar"),
|
|
||||||
clearable=False,
|
|
||||||
style={"flex": 1}
|
|
||||||
),
|
|
||||||
"Wählen Sie das Datum für den Termin aus dem Kalender"
|
|
||||||
),
|
|
||||||
|
|
||||||
# Zeitbereich - nebeneinander
|
|
||||||
dmc.Grid([
|
|
||||||
dmc.GridCol([
|
|
||||||
dmc.Stack([
|
|
||||||
create_input_with_tooltip_time(
|
|
||||||
dmc.Select(
|
|
||||||
label="Startzeit",
|
|
||||||
placeholder="Zeit auswählen",
|
|
||||||
data=time_options,
|
|
||||||
searchable=True,
|
|
||||||
clearable=True,
|
|
||||||
id="time-start",
|
|
||||||
value="09:00",
|
|
||||||
required=True,
|
|
||||||
style={"flex": 1}
|
|
||||||
),
|
|
||||||
"Eingabe: 10:25, 10.25, 1025 oder 10 (wird automatisch formatiert)"
|
|
||||||
),
|
|
||||||
html.Div(id="start-time-feedback")
|
|
||||||
], gap="xs")
|
|
||||||
], span=6),
|
|
||||||
dmc.GridCol([
|
|
||||||
dmc.Stack([
|
|
||||||
create_input_with_tooltip_time(
|
|
||||||
dmc.Select(
|
|
||||||
label="Endzeit",
|
|
||||||
placeholder="Zeit auswählen",
|
|
||||||
data=time_options,
|
|
||||||
searchable=True,
|
|
||||||
clearable=True,
|
|
||||||
id="time-end",
|
|
||||||
required=True,
|
|
||||||
style={"flex": 1}
|
|
||||||
),
|
|
||||||
"Endzeit muss nach der Startzeit am selben Tag liegen. Termine können nicht über Mitternacht hinausgehen."
|
|
||||||
),
|
|
||||||
html.Div(id="end-time-feedback")
|
|
||||||
], gap="xs")
|
|
||||||
], span=6)
|
|
||||||
]),
|
|
||||||
|
|
||||||
create_input_with_tooltip_full(
|
|
||||||
dmc.Select(
|
|
||||||
label="Termintyp",
|
|
||||||
placeholder="Typ auswählen",
|
|
||||||
data=[
|
|
||||||
{"value": "presentation", "label": "Präsentation"},
|
|
||||||
{"value": "website", "label": "Website"},
|
|
||||||
{"value": "video", "label": "Video"},
|
|
||||||
{"value": "message", "label": "Nachricht"},
|
|
||||||
{"value": "other", "label": "Sonstiges"}
|
|
||||||
],
|
|
||||||
id="type-input",
|
|
||||||
required=True,
|
|
||||||
style={"flex": 1}
|
|
||||||
),
|
|
||||||
"Wählen Sie den Typ des Termins für bessere Kategorisierung"
|
|
||||||
),
|
|
||||||
|
|
||||||
# Dynamische typ-spezifische Felder
|
|
||||||
html.Div(id="type-specific-fields"),
|
|
||||||
|
|
||||||
create_input_with_tooltip_full(
|
|
||||||
dmc.Textarea(
|
|
||||||
label="Beschreibung",
|
|
||||||
placeholder="Zusätzliche Informationen...",
|
|
||||||
minRows=3,
|
|
||||||
autosize=True,
|
|
||||||
id="description-input",
|
|
||||||
style={"flex": 1}
|
|
||||||
),
|
|
||||||
"Optionale Beschreibung mit weiteren Details zum Termin"
|
|
||||||
)
|
|
||||||
], gap="md")
|
|
||||||
], p="md", shadow="sm")
|
|
||||||
], span=6),
|
|
||||||
|
|
||||||
dmc.GridCol([
|
|
||||||
dmc.Paper([
|
|
||||||
dmc.Title("Wiederholung", order=3, className="mb-3"),
|
|
||||||
|
|
||||||
dmc.Stack([
|
|
||||||
create_input_with_tooltip_full(
|
|
||||||
dmc.Checkbox(
|
|
||||||
label="Wiederholender Termin",
|
|
||||||
id="repeat-checkbox",
|
|
||||||
description="Aktivieren für wöchentliche Wiederholung"
|
|
||||||
),
|
|
||||||
"Aktivieren Sie diese Option, um den Termin an mehreren Wochentagen zu wiederholen"
|
|
||||||
),
|
|
||||||
|
|
||||||
html.Div(id="repeat-options", children=[
|
|
||||||
create_input_with_tooltip_full(
|
|
||||||
dmc.MultiSelect(
|
|
||||||
label="Wochentage",
|
|
||||||
placeholder="Wochentage auswählen",
|
|
||||||
data=weekday_options,
|
|
||||||
id="weekdays-select",
|
|
||||||
description="An welchen Wochentagen soll der Termin stattfinden?",
|
|
||||||
disabled=True,
|
|
||||||
style={"flex": 1}
|
|
||||||
),
|
|
||||||
"Wählen Sie mindestens einen Wochentag für die Wiederholung aus"
|
|
||||||
),
|
|
||||||
|
|
||||||
dmc.Space(h="md"), # Abstand zwischen DatePicker und Ferientage
|
|
||||||
|
|
||||||
create_input_with_tooltip_full(
|
|
||||||
dmc.DatePickerInput(
|
|
||||||
label="Wiederholung bis",
|
|
||||||
id="repeat-until-date",
|
|
||||||
firstDayOfWeek=1,
|
|
||||||
weekendDays=[0, 6],
|
|
||||||
valueFormat="DD.MM.YYYY",
|
|
||||||
placeholder="Enddatum auswählen",
|
|
||||||
leftSection=DashIconify(icon="mdi:calendar-end"),
|
|
||||||
description="Letzter Tag der Wiederholung",
|
|
||||||
disabled=True,
|
|
||||||
clearable=True,
|
|
||||||
style={"flex": 1}
|
|
||||||
),
|
|
||||||
"Datum bis wann die Wiederholung stattfinden soll. Muss nach dem Startdatum liegen."
|
|
||||||
),
|
|
||||||
|
|
||||||
dmc.Space(h="lg"), # Größerer Abstand vor Ferientage
|
|
||||||
|
|
||||||
create_input_with_tooltip_full(
|
|
||||||
dmc.Checkbox(
|
|
||||||
label="Ferientage berücksichtigen",
|
|
||||||
id="skip-holidays-checkbox",
|
|
||||||
description="Termine an Feiertagen und in Schulferien auslassen",
|
|
||||||
disabled=True
|
|
||||||
),
|
|
||||||
"Aktivieren Sie diese Option, um Termine an deutschen Feiertagen und in Schulferien automatisch zu überspringen"
|
|
||||||
)
|
|
||||||
])
|
|
||||||
], gap="md")
|
|
||||||
], p="md", shadow="sm"),
|
|
||||||
|
|
||||||
dmc.Paper([
|
|
||||||
dmc.Title("Aktionen", order=3, className="mb-3"),
|
|
||||||
|
|
||||||
dmc.Stack([
|
|
||||||
dmc.Button(
|
|
||||||
"Termin(e) speichern",
|
|
||||||
color="green",
|
|
||||||
leftSection=DashIconify(icon="mdi:content-save"),
|
|
||||||
id="btn-save",
|
|
||||||
size="lg",
|
|
||||||
fullWidth=True
|
|
||||||
),
|
|
||||||
|
|
||||||
dmc.Button(
|
|
||||||
"Zurücksetzen",
|
|
||||||
color="gray",
|
|
||||||
variant="outline",
|
|
||||||
leftSection=DashIconify(icon="mdi:refresh"),
|
|
||||||
id="btn-reset",
|
|
||||||
fullWidth=True
|
|
||||||
),
|
|
||||||
|
|
||||||
html.Div(id="save-feedback", className="mt-3")
|
|
||||||
], gap="md")
|
|
||||||
], p="md", shadow="sm", className="mt-3")
|
|
||||||
], span=6)
|
|
||||||
]),
|
|
||||||
|
|
||||||
# Vorschau-Bereich
|
|
||||||
dmc.Paper([
|
|
||||||
dmc.Title("Vorschau", order=3, className="mb-3"),
|
|
||||||
html.Div(id="preview-area")
|
|
||||||
], p="md", shadow="sm", className="mt-4")
|
|
||||||
], size="lg")
|
|
||||||
])
|
|
||||||
])
|
|
||||||
|
|
||||||
# Zeit-Validierungsfunktion
|
|
||||||
def validate_and_format_time(time_str):
|
|
||||||
"""Validiert und formatiert Zeitangaben"""
|
|
||||||
if not time_str:
|
|
||||||
return None, "Keine Zeit angegeben"
|
|
||||||
|
|
||||||
# Bereits korrektes Format
|
|
||||||
if re.match(r'^\d{2}:\d{2}$', time_str):
|
|
||||||
try:
|
|
||||||
h, m = map(int, time_str.split(':'))
|
|
||||||
if 0 <= h <= 23 and 0 <= m <= 59:
|
|
||||||
return time_str, "Gültige Zeit"
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Verschiedene Eingabeformate versuchen
|
|
||||||
patterns = [
|
|
||||||
(r'^(\d{1,2}):(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
|
|
||||||
(r'^(\d{1,2})\.(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
|
|
||||||
(r'^(\d{1,2})(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
|
|
||||||
(r'^(\d{1,2})$', lambda m: (int(m.group(1)), 0)),
|
|
||||||
]
|
|
||||||
|
|
||||||
for pattern, extractor in patterns:
|
|
||||||
match = re.match(pattern, time_str.strip())
|
|
||||||
if match:
|
|
||||||
try:
|
|
||||||
hours, minutes = extractor(match)
|
|
||||||
if 0 <= hours <= 23 and 0 <= minutes <= 59:
|
|
||||||
return f"{hours:02d}:{minutes:02d}", "Automatisch formatiert"
|
|
||||||
except:
|
|
||||||
continue
|
|
||||||
|
|
||||||
return None, "Ungültiges Zeitformat"
|
|
||||||
|
|
||||||
# Typ-spezifische Felder anzeigen
|
|
||||||
@callback(
|
|
||||||
Output('type-specific-fields', 'children'),
|
|
||||||
Input('type-input', 'value'),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def show_type_specific_fields(event_type):
|
|
||||||
if not event_type:
|
|
||||||
return html.Div()
|
|
||||||
|
|
||||||
if event_type == "presentation":
|
|
||||||
return dmc.Stack([
|
|
||||||
dmc.Divider(label="Präsentations-Details", labelPosition="center"),
|
|
||||||
dmc.Group([
|
|
||||||
dcc.Upload(
|
|
||||||
id='presentation-upload',
|
|
||||||
children=dmc.Button(
|
|
||||||
"Datei hochladen",
|
|
||||||
leftSection=DashIconify(icon="mdi:upload"),
|
|
||||||
variant="outline"
|
|
||||||
),
|
|
||||||
style={'width': 'auto'}
|
|
||||||
),
|
|
||||||
dmc.TextInput(
|
|
||||||
label="Präsentationslink",
|
|
||||||
placeholder="https://...",
|
|
||||||
leftSection=DashIconify(icon="mdi:link"),
|
|
||||||
id="presentation-link",
|
|
||||||
style={"minWidth": 0, "flex": 1, "marginBottom": 0}
|
|
||||||
)
|
|
||||||
], grow=True, align="flex-end", style={"marginBottom": 10}),
|
|
||||||
html.Div(id="presentation-upload-status")
|
|
||||||
], gap="sm")
|
|
||||||
|
|
||||||
elif event_type == "video":
|
|
||||||
return dmc.Stack([
|
|
||||||
dmc.Divider(label="Video-Details", labelPosition="center"),
|
|
||||||
dmc.Group([
|
|
||||||
dcc.Upload(
|
|
||||||
id='video-upload',
|
|
||||||
children=dmc.Button(
|
|
||||||
"Video hochladen",
|
|
||||||
leftSection=DashIconify(icon="mdi:video-plus"),
|
|
||||||
variant="outline"
|
|
||||||
),
|
|
||||||
style={'width': 'auto'}
|
|
||||||
),
|
|
||||||
dmc.TextInput(
|
|
||||||
label="Videolink",
|
|
||||||
placeholder="https://youtube.com/...",
|
|
||||||
leftSection=DashIconify(icon="mdi:youtube"),
|
|
||||||
id="video-link",
|
|
||||||
style={"minWidth": 0, "flex": 1, "marginBottom": 0}
|
|
||||||
)
|
|
||||||
], grow=True, align="flex-end", style={"marginBottom": 10}),
|
|
||||||
dmc.Group([
|
|
||||||
dmc.Checkbox(
|
|
||||||
label="Endlos wiederholen",
|
|
||||||
id="video-endless",
|
|
||||||
checked=True,
|
|
||||||
style={"marginRight": 20}
|
|
||||||
),
|
|
||||||
dmc.NumberInput(
|
|
||||||
label="Wiederholungen",
|
|
||||||
id="video-repeats",
|
|
||||||
value=1,
|
|
||||||
min=1,
|
|
||||||
max=99,
|
|
||||||
step=1,
|
|
||||||
disabled=True,
|
|
||||||
style={"width": 150}
|
|
||||||
),
|
|
||||||
dmc.Slider(
|
|
||||||
label="Lautstärke",
|
|
||||||
id="video-volume",
|
|
||||||
value=70,
|
|
||||||
min=0,
|
|
||||||
max=100,
|
|
||||||
step=5,
|
|
||||||
marks=[
|
|
||||||
{"value": 0, "label": "0%"},
|
|
||||||
{"value": 50, "label": "50%"},
|
|
||||||
{"value": 100, "label": "100%"}
|
|
||||||
],
|
|
||||||
style={"flex": 1, "marginLeft": 20}
|
|
||||||
)
|
|
||||||
], grow=True, align="flex-end"),
|
|
||||||
html.Div(id="video-upload-status")
|
|
||||||
], gap="sm")
|
|
||||||
|
|
||||||
elif event_type == "website":
|
|
||||||
return dmc.Stack([
|
|
||||||
dmc.Divider(label="Website-Details", labelPosition="center"),
|
|
||||||
dmc.TextInput(
|
|
||||||
label="Website-URL",
|
|
||||||
placeholder="https://example.com",
|
|
||||||
leftSection=DashIconify(icon="mdi:web"),
|
|
||||||
id="website-url",
|
|
||||||
required=True,
|
|
||||||
style={"flex": 1}
|
|
||||||
)
|
|
||||||
# Anzeigedauer entfernt!
|
|
||||||
], gap="sm")
|
|
||||||
|
|
||||||
elif event_type == "message":
|
|
||||||
return dmc.Stack([
|
|
||||||
dmc.Divider(label="Nachrichten-Details", labelPosition="center"),
|
|
||||||
dash_quill.Quill(
|
|
||||||
id="message-content",
|
|
||||||
value="",
|
|
||||||
# theme="snow",
|
|
||||||
# style={"height": "150px", "marginBottom": 10}
|
|
||||||
),
|
|
||||||
dmc.Group([
|
|
||||||
dmc.Select(
|
|
||||||
label="Schriftgröße",
|
|
||||||
data=[
|
|
||||||
{"value": "small", "label": "Klein"},
|
|
||||||
{"value": "medium", "label": "Normal"},
|
|
||||||
{"value": "large", "label": "Groß"},
|
|
||||||
{"value": "xlarge", "label": "Sehr groß"}
|
|
||||||
],
|
|
||||||
id="message-font-size",
|
|
||||||
value="medium",
|
|
||||||
style={"flex": 1}
|
|
||||||
),
|
|
||||||
dmc.ColorPicker(
|
|
||||||
id="message-color",
|
|
||||||
value="#000000",
|
|
||||||
format="hex",
|
|
||||||
swatches=[
|
|
||||||
"#000000", "#ffffff", "#ff0000", "#00ff00",
|
|
||||||
"#0000ff", "#ffff00", "#ff00ff", "#00ffff"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
], grow=True, align="flex-end")
|
|
||||||
], gap="sm")
|
|
||||||
|
|
||||||
return html.Div()
|
|
||||||
|
|
||||||
# Callback zum Aktivieren/Deaktivieren des Wiederholungsfelds bei Video
|
|
||||||
@callback(
|
|
||||||
Output("video-repeats", "disabled"),
|
|
||||||
Input("video-endless", "checked"),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def toggle_video_repeats(endless_checked):
|
|
||||||
return endless_checked
|
|
||||||
|
|
||||||
# Upload-Status für Präsentation
|
|
||||||
@callback(
|
|
||||||
Output('presentation-upload-status', 'children'),
|
|
||||||
Input('presentation-upload', 'contents'),
|
|
||||||
State('presentation-upload', 'filename'),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def update_presentation_upload_status(contents, filename):
|
|
||||||
"""Zeigt Status des Präsentations-Uploads"""
|
|
||||||
if contents is not None and filename is not None:
|
|
||||||
return dmc.Alert(
|
|
||||||
f"✓ Datei '{filename}' erfolgreich hochgeladen",
|
|
||||||
color="green",
|
|
||||||
className="mt-2"
|
|
||||||
)
|
|
||||||
return html.Div()
|
|
||||||
|
|
||||||
# Upload-Status für Video
|
|
||||||
@callback(
|
|
||||||
Output('video-upload-status', 'children'),
|
|
||||||
Input('video-upload', 'contents'),
|
|
||||||
State('video-upload', 'filename'),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def update_video_upload_status(contents, filename):
|
|
||||||
"""Zeigt Status des Video-Uploads"""
|
|
||||||
if contents is not None and filename is not None:
|
|
||||||
return dmc.Alert(
|
|
||||||
f"✓ Video '{filename}' erfolgreich hochgeladen",
|
|
||||||
color="green",
|
|
||||||
className="mt-2"
|
|
||||||
)
|
|
||||||
return html.Div()
|
|
||||||
|
|
||||||
# Wiederholungsoptionen aktivieren/deaktivieren
|
|
||||||
@callback(
|
|
||||||
[
|
|
||||||
Output('weekdays-select', 'disabled'),
|
|
||||||
Output('repeat-until-date', 'disabled'),
|
|
||||||
Output('skip-holidays-checkbox', 'disabled'),
|
|
||||||
Output('weekdays-select', 'value'),
|
|
||||||
Output('repeat-until-date', 'value'),
|
|
||||||
Output('skip-holidays-checkbox', 'checked')
|
|
||||||
],
|
|
||||||
Input('repeat-checkbox', 'checked'),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def toggle_repeat_options(is_repeat):
|
|
||||||
"""Aktiviert/deaktiviert Wiederholungsoptionen"""
|
|
||||||
if is_repeat:
|
|
||||||
# Aktiviert und setzt Standardwerte
|
|
||||||
next_month = datetime.now().date() + timedelta(weeks=4) # 4 Wochen später
|
|
||||||
return (
|
|
||||||
False, # weekdays-select enabled
|
|
||||||
False, # repeat-until-date enabled
|
|
||||||
False, # skip-holidays-checkbox enabled
|
|
||||||
None, # weekdays value
|
|
||||||
next_month, # repeat-until-date value
|
|
||||||
False # skip-holidays-checkbox checked
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Deaktiviert und löscht Werte
|
|
||||||
return (
|
|
||||||
True, # weekdays-select disabled
|
|
||||||
True, # repeat-until-date disabled
|
|
||||||
True, # skip-holidays-checkbox disabled
|
|
||||||
None, # weekdays value
|
|
||||||
None, # repeat-until-date value
|
|
||||||
False # skip-holidays-checkbox checked
|
|
||||||
)
|
|
||||||
|
|
||||||
# Dynamische Zeitoptionen für Startzeit
|
|
||||||
@callback(
|
|
||||||
[
|
|
||||||
Output('time-start', 'data'),
|
|
||||||
Output('start-time-feedback', 'children')
|
|
||||||
],
|
|
||||||
Input('time-start', 'searchValue'),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def update_start_time_options(search_value):
|
|
||||||
"""Erweitert Zeitoptionen basierend auf Sucheingabe"""
|
|
||||||
base_options = time_options.copy()
|
|
||||||
feedback = None
|
|
||||||
|
|
||||||
if search_value:
|
|
||||||
validated_time, status = validate_and_format_time(search_value)
|
|
||||||
|
|
||||||
if validated_time:
|
|
||||||
if not any(opt["value"] == validated_time for opt in base_options):
|
|
||||||
base_options.insert(0, {
|
|
||||||
"value": validated_time,
|
|
||||||
"label": f"{validated_time} (Ihre Eingabe)"
|
|
||||||
})
|
|
||||||
|
|
||||||
feedback = dmc.Text(f"✓ {status}: {validated_time}", size="xs", c="green")
|
|
||||||
else:
|
|
||||||
feedback = dmc.Text(f"✗ {status}", size="xs", c="red")
|
|
||||||
|
|
||||||
return base_options, feedback
|
|
||||||
|
|
||||||
# Dynamische Zeitoptionen für Endzeit
|
|
||||||
@callback(
|
|
||||||
[
|
|
||||||
Output('time-end', 'data'),
|
|
||||||
Output('end-time-feedback', 'children')
|
|
||||||
],
|
|
||||||
Input('time-end', 'searchValue'),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def update_end_time_options(search_value):
|
|
||||||
"""Erweitert Zeitoptionen basierend auf Sucheingabe"""
|
|
||||||
base_options = time_options.copy()
|
|
||||||
feedback = None
|
|
||||||
|
|
||||||
if search_value:
|
|
||||||
validated_time, status = validate_and_format_time(search_value)
|
|
||||||
|
|
||||||
if validated_time:
|
|
||||||
if not any(opt["value"] == validated_time for opt in base_options):
|
|
||||||
base_options.insert(0, {
|
|
||||||
"value": validated_time,
|
|
||||||
"label": f"{validated_time} (Ihre Eingabe)"
|
|
||||||
})
|
|
||||||
|
|
||||||
feedback = dmc.Text(f"✓ {status}: {validated_time}", size="xs", c="green")
|
|
||||||
else:
|
|
||||||
feedback = dmc.Text(f"✗ {status}", size="xs", c="red")
|
|
||||||
|
|
||||||
return base_options, feedback
|
|
||||||
|
|
||||||
# Automatische Endzeit-Berechnung mit Validation
|
|
||||||
@callback(
|
|
||||||
Output('time-end', 'value'),
|
|
||||||
[
|
|
||||||
Input('time-start', 'value'),
|
|
||||||
Input('btn-reset', 'n_clicks')
|
|
||||||
],
|
|
||||||
State('time-end', 'value'),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def handle_end_time(start_time, reset_clicks, current_end_time):
|
|
||||||
"""Behandelt automatische Endzeit-Berechnung und Reset"""
|
|
||||||
ctx = dash.callback_context
|
|
||||||
if not ctx.triggered:
|
|
||||||
return dash.no_update
|
|
||||||
|
|
||||||
trigger_id = ctx.triggered[0]['prop_id'].split('.')[0]
|
|
||||||
|
|
||||||
if trigger_id == 'btn-reset' and reset_clicks:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if trigger_id == 'time-start' and start_time:
|
|
||||||
if current_end_time:
|
|
||||||
return dash.no_update
|
|
||||||
|
|
||||||
try:
|
|
||||||
validated_start, _ = validate_and_format_time(start_time)
|
|
||||||
if validated_start:
|
|
||||||
start_dt = datetime.strptime(validated_start, "%H:%M")
|
|
||||||
# 1.5 Stunden später, aber maximal 23:59
|
|
||||||
end_dt = start_dt + timedelta(hours=1, minutes=30)
|
|
||||||
if end_dt.hour >= 24:
|
|
||||||
end_dt = end_dt.replace(hour=23, minute=59)
|
|
||||||
return end_dt.strftime("%H:%M")
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return dash.no_update
|
|
||||||
|
|
||||||
# Hilfsfunktion für sichere Werte-Abfrage
|
|
||||||
def get_safe_value(ctx, prop_id):
|
|
||||||
"""Gibt den Wert einer Property zurück oder None, wenn sie nicht existiert"""
|
|
||||||
try:
|
|
||||||
return ctx.states.get(prop_id, {}).get('value')
|
|
||||||
except:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Vorschau-Bereich mit Ferientags-Berücksichtigung und typ-spezifischen Daten
|
|
||||||
@callback(
|
|
||||||
Output('preview-area', 'children'),
|
|
||||||
[
|
|
||||||
Input('title-input', 'value'),
|
|
||||||
Input('start-date-input', 'value'),
|
|
||||||
Input('time-start', 'value'),
|
|
||||||
Input('time-end', 'value'),
|
|
||||||
Input('type-input', 'value'),
|
|
||||||
Input('description-input', 'value'),
|
|
||||||
Input('repeat-checkbox', 'checked'),
|
|
||||||
Input('weekdays-select', 'value'),
|
|
||||||
Input('repeat-until-date', 'value'),
|
|
||||||
Input('skip-holidays-checkbox', 'checked')
|
|
||||||
],
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def update_preview(title, start_date, start_time, end_time, event_type, description,
|
|
||||||
is_repeat, weekdays, repeat_until, skip_holidays):
|
|
||||||
"""Zeigt Live-Vorschau der Termine mit typ-spezifischen Daten"""
|
|
||||||
|
|
||||||
validated_start, start_status = validate_and_format_time(start_time)
|
|
||||||
validated_end, end_status = validate_and_format_time(end_time)
|
|
||||||
|
|
||||||
# Zeitvalidierung
|
|
||||||
time_valid = True
|
|
||||||
time_error = ""
|
|
||||||
|
|
||||||
if validated_start and validated_end:
|
|
||||||
start_dt = datetime.strptime(validated_start, "%H:%M")
|
|
||||||
end_dt = datetime.strptime(validated_end, "%H:%M")
|
|
||||||
|
|
||||||
if end_dt <= start_dt:
|
|
||||||
time_valid = False
|
|
||||||
time_error = "Endzeit muss nach Startzeit liegen"
|
|
||||||
elif end_dt.hour < start_dt.hour: # Über Mitternacht
|
|
||||||
time_valid = False
|
|
||||||
time_error = "Termine dürfen nicht über Mitternacht hinausgehen"
|
|
||||||
|
|
||||||
# Typ-spezifische Details mit sicherer Abfrage
|
|
||||||
type_details = []
|
|
||||||
if event_type == "presentation":
|
|
||||||
# Hier würden wir normalerweise die Werte abfragen, aber da sie dynamisch sind,
|
|
||||||
# zeigen wir nur den Typ an
|
|
||||||
type_details.append(dmc.Text("🎯 Präsentationsdetails werden nach Auswahl angezeigt", size="sm"))
|
|
||||||
elif event_type == "video":
|
|
||||||
type_details.append(dmc.Text("📹 Videodetails werden nach Auswahl angezeigt", size="sm"))
|
|
||||||
elif event_type == "website":
|
|
||||||
type_details.append(dmc.Text("🌐 Website-Details werden nach Auswahl angezeigt", size="sm"))
|
|
||||||
elif event_type == "message":
|
|
||||||
type_details.append(dmc.Text("💬 Nachrichten-Details werden nach Auswahl angezeigt", size="sm"))
|
|
||||||
|
|
||||||
# Wiederholungslogik mit Ferientags-Berücksichtigung
|
|
||||||
if is_repeat and weekdays and start_date and repeat_until and time_valid:
|
|
||||||
weekday_names = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
|
|
||||||
selected_days = [weekday_names[int(day)] for day in weekdays]
|
|
||||||
|
|
||||||
# Termine berechnen
|
|
||||||
termine_count = 0
|
|
||||||
skipped_holidays = 0
|
|
||||||
|
|
||||||
# Sicherstellen, dass start_date ein date-Objekt ist
|
|
||||||
if isinstance(start_date, str):
|
|
||||||
try:
|
|
||||||
current_date = datetime.strptime(start_date, "%Y-%m-%d").date()
|
|
||||||
except:
|
|
||||||
current_date = datetime.now().date()
|
|
||||||
else:
|
|
||||||
current_date = start_date
|
|
||||||
|
|
||||||
# Sicherstellen, dass repeat_until ein date-Objekt ist
|
|
||||||
if isinstance(repeat_until, str):
|
|
||||||
try:
|
|
||||||
end_date = datetime.strptime(repeat_until, "%Y-%m-%d").date()
|
|
||||||
except:
|
|
||||||
end_date = current_date + timedelta(weeks=4)
|
|
||||||
else:
|
|
||||||
end_date = repeat_until
|
|
||||||
|
|
||||||
# Kopie für Iteration erstellen
|
|
||||||
iter_date = current_date
|
|
||||||
while iter_date <= end_date:
|
|
||||||
if str(iter_date.weekday()) in weekdays:
|
|
||||||
if skip_holidays and is_holiday_or_vacation(iter_date):
|
|
||||||
skipped_holidays += 1
|
|
||||||
else:
|
|
||||||
termine_count += 1
|
|
||||||
iter_date += timedelta(days=1)
|
|
||||||
|
|
||||||
holiday_info = []
|
|
||||||
if skip_holidays:
|
|
||||||
holiday_info = [
|
|
||||||
dmc.Text(f"🚫 Übersprungene Ferientage: {skipped_holidays}", size="sm", c="orange"),
|
|
||||||
dmc.Text(f"📅 Tatsächliche Termine: {termine_count}", size="sm", fw=500)
|
|
||||||
]
|
|
||||||
|
|
||||||
repeat_info = dmc.Stack([
|
|
||||||
dmc.Text(f"📅 Wiederholung: {', '.join(selected_days)}", size="sm"),
|
|
||||||
dmc.Text(f"📆 Zeitraum: {current_date.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}", size="sm"),
|
|
||||||
dmc.Text(f"🔢 Geplante Termine: {termine_count + skipped_holidays if skip_holidays else termine_count}", size="sm"),
|
|
||||||
*holiday_info
|
|
||||||
])
|
|
||||||
else:
|
|
||||||
repeat_info = dmc.Text("📅 Einzeltermin", size="sm")
|
|
||||||
|
|
||||||
# Datum formatieren
|
|
||||||
date_str = start_date.strftime('%d.%m.%Y') if isinstance(start_date, date) else (start_date or "Nicht gesetzt")
|
|
||||||
|
|
||||||
return dmc.Stack([
|
|
||||||
dmc.Title(title or "Unbenannter Termin", order=4),
|
|
||||||
dmc.Text(f"📅 Datum: {date_str}", size="sm"),
|
|
||||||
dmc.Text(f"🕐 Zeit: {validated_start or 'Nicht gesetzt'} - {validated_end or 'Nicht gesetzt'}", size="sm"),
|
|
||||||
dmc.Text(f"📋 Typ: {event_type or 'Nicht gesetzt'}", size="sm"),
|
|
||||||
|
|
||||||
# Typ-spezifische Details
|
|
||||||
*type_details,
|
|
||||||
|
|
||||||
dmc.Text(f"📝 Beschreibung: {description[:100] + '...' if description and len(description) > 100 else description or 'Keine'}", size="sm"),
|
|
||||||
|
|
||||||
dmc.Divider(className="my-2"),
|
|
||||||
|
|
||||||
repeat_info,
|
|
||||||
|
|
||||||
dmc.Divider(className="my-2"),
|
|
||||||
|
|
||||||
dmc.Stack([
|
|
||||||
dmc.Text("Validierung:", fw=500, size="xs"),
|
|
||||||
dmc.Text(f"Start: {start_status}", size="xs", c="green" if validated_start else "red"),
|
|
||||||
dmc.Text(f"Ende: {end_status}", size="xs", c="green" if validated_end else "red"),
|
|
||||||
dmc.Text(f"Zeitbereich: {'✓ Gültig' if time_valid else f'✗ {time_error}'}",
|
|
||||||
size="xs", c="green" if time_valid else "red")
|
|
||||||
], gap="xs")
|
|
||||||
])
|
|
||||||
|
|
||||||
# Reset-Funktion erweitert
|
|
||||||
@callback(
|
|
||||||
[
|
|
||||||
Output('title-input', 'value'),
|
|
||||||
Output('start-date-input', 'value'),
|
|
||||||
Output('time-start', 'value'),
|
|
||||||
Output('type-input', 'value'),
|
|
||||||
Output('description-input', 'value'),
|
|
||||||
Output('repeat-checkbox', 'checked'),
|
|
||||||
Output('weekdays-select', 'value', allow_duplicate=True),
|
|
||||||
Output('repeat-until-date', 'value', allow_duplicate=True),
|
|
||||||
Output('skip-holidays-checkbox', 'checked', allow_duplicate=True)
|
|
||||||
],
|
|
||||||
Input('btn-reset', 'n_clicks'),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def reset_form(n_clicks):
|
|
||||||
"""Setzt das komplette Formular zurück"""
|
|
||||||
if n_clicks:
|
|
||||||
return "", datetime.now().date(), "09:00", None, "", False, None, None, False
|
|
||||||
return dash.no_update
|
|
||||||
|
|
||||||
# Speichern-Funktion (vereinfacht für Demo)
|
|
||||||
@callback(
|
|
||||||
Output('save-feedback', 'children'),
|
|
||||||
Input('btn-save', 'n_clicks'),
|
|
||||||
prevent_initial_call=True
|
|
||||||
)
|
|
||||||
def save_appointments_demo(n_clicks):
|
|
||||||
"""Demo-Speicherfunktion"""
|
|
||||||
if not n_clicks:
|
|
||||||
return dash.no_update
|
|
||||||
|
|
||||||
return dmc.Alert(
|
|
||||||
"Demo: Termine würden hier gespeichert werden",
|
|
||||||
color="blue",
|
|
||||||
title="Speichern (Demo-Modus)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app.run(debug=True, host="0.0.0.0", port=8051)
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# dashboard/pages/appointments.py
|
|
||||||
from dash import html, dcc
|
|
||||||
import dash
|
|
||||||
from dash_using_fullcalendar import DashUsingFullcalendar
|
|
||||||
import dash_bootstrap_components as dbc
|
|
||||||
from dashboard.components.appointment_modal import get_appointment_modal
|
|
||||||
|
|
||||||
dash.register_page(__name__, path="/appointments", name="Termine")
|
|
||||||
|
|
||||||
layout = dbc.Container([
|
|
||||||
dbc.Row([
|
|
||||||
dbc.Col(html.H2("Dash FullCalendar"))
|
|
||||||
]),
|
|
||||||
# Button zum Öffnen der Modalbox
|
|
||||||
dbc.Row([
|
|
||||||
dbc.Col(
|
|
||||||
dbc.Button(
|
|
||||||
"Neuen Termin anlegen",
|
|
||||||
id="open-appointment-modal-btn",
|
|
||||||
color="primary",
|
|
||||||
className="mb-3"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]),
|
|
||||||
dbc.Row([
|
|
||||||
dbc.Col(
|
|
||||||
DashUsingFullcalendar(
|
|
||||||
id='calendar',
|
|
||||||
events=[],
|
|
||||||
initialView="timeGridWeek",
|
|
||||||
headerToolbar={
|
|
||||||
"left": "prev,next today",
|
|
||||||
"center": "title",
|
|
||||||
# "right": "dayGridMonth,timeGridWeek,timeGridDay"
|
|
||||||
},
|
|
||||||
height=600,
|
|
||||||
locale="de",
|
|
||||||
slotDuration="00:30:00",
|
|
||||||
slotMinTime="00:00:00",
|
|
||||||
slotMaxTime="24:00:00",
|
|
||||||
scrollTime="07:00:00",
|
|
||||||
weekends=True,
|
|
||||||
allDaySlot=False,
|
|
||||||
firstDay=1,
|
|
||||||
# themeSystem kann auf "bootstrap5" gesetzt werden, wenn das Plugin eingebunden ist
|
|
||||||
# themeSystem="bootstrap5"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]),
|
|
||||||
dbc.Row([
|
|
||||||
dbc.Col(html.Div(id='output'))
|
|
||||||
]),
|
|
||||||
dbc.Row([
|
|
||||||
dbc.Col(html.Div(id='event-output'))
|
|
||||||
]),
|
|
||||||
dbc.Row([
|
|
||||||
dbc.Col(html.Div(id='select-output'))
|
|
||||||
]),
|
|
||||||
dbc.Row([
|
|
||||||
dbc.Col(html.Div(id='modal-output', children=get_appointment_modal()))
|
|
||||||
])
|
|
||||||
], fluid=True)
|
|
||||||
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# dashboard/pages/clients.py
|
|
||||||
from dash import html, dcc
|
|
||||||
import dash
|
|
||||||
|
|
||||||
dash.register_page(__name__, path="/clients", name="Bildschirme")
|
|
||||||
|
|
||||||
layout = html.Div(
|
|
||||||
className="clients-page",
|
|
||||||
children=[
|
|
||||||
html.H3("Bildschirme"),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# dashboard/pages/login.py
|
|
||||||
from dash import html, dcc
|
|
||||||
import dash
|
|
||||||
|
|
||||||
dash.register_page(__name__, path="/login", name="Login")
|
|
||||||
|
|
||||||
layout = html.Div(
|
|
||||||
className="login-page",
|
|
||||||
children=[
|
|
||||||
html.H2("Bitte einloggen"),
|
|
||||||
dcc.Input(id="input-user", type="text", placeholder="Benutzername"),
|
|
||||||
dcc.Input(id="input-pass", type="password", placeholder="Passwort"),
|
|
||||||
html.Button("Einloggen", id="btn-login"),
|
|
||||||
html.Div(id="login-feedback", className="text-danger")
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# dashboard/pages/overview.py
|
|
||||||
from dash import html, dcc
|
|
||||||
import dash
|
|
||||||
|
|
||||||
dash.register_page(__name__, path="/overview", name="Übersicht")
|
|
||||||
|
|
||||||
layout = html.Div(
|
|
||||||
className="overview-page",
|
|
||||||
children=[
|
|
||||||
dcc.Interval(id="interval-update", interval=10_000, n_intervals=0),
|
|
||||||
html.Div(id="clients-cards-container")
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# dashboard/pages/settings.py
|
|
||||||
from dash import html
|
|
||||||
import dash
|
|
||||||
|
|
||||||
dash.register_page(__name__, path="/settings", name="Einstellungen")
|
|
||||||
|
|
||||||
layout = html.Div(
|
|
||||||
className="settings-page",
|
|
||||||
children=[
|
|
||||||
html.H3("Allgemeine Einstellungen"),
|
|
||||||
# Formularfelder / Tabs für globale Optionen
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import dash
|
|
||||||
from dash import html
|
|
||||||
|
|
||||||
dash.register_page(__name__, path="/test", name="Testseite")
|
|
||||||
layout = html.Div("Testseite funktioniert!")
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# dashboard/pages/users.py
|
|
||||||
from dash import html, dash_table, dcc
|
|
||||||
import dash
|
|
||||||
|
|
||||||
dash.register_page(__name__, path="/users", name="Benutzer")
|
|
||||||
|
|
||||||
layout = html.Div(
|
|
||||||
className="users-page",
|
|
||||||
children=[
|
|
||||||
html.H3("Benutzerverwaltung"),
|
|
||||||
html.Button("Neuen Benutzer anlegen", id="btn-new-user"),
|
|
||||||
html.Div(id="users-table-container"),
|
|
||||||
html.Div(id="users-feedback")
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
debugpy
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
bcrypt>=4.3.0
|
|
||||||
dash>=3.0.4
|
|
||||||
dash-bootstrap-components>=2.0.3
|
|
||||||
dash_iconify>=0.1.2
|
|
||||||
dash_mantine_components>=1.2.0
|
|
||||||
dash-quill>=0.0.4
|
|
||||||
full-calendar-component>=0.0.4
|
|
||||||
pandas>=2.2.3
|
|
||||||
paho-mqtt>=2.1.0
|
|
||||||
python-dotenv>=1.1.0
|
|
||||||
PyMySQL>=1.1.1
|
|
||||||
SQLAlchemy>=2.0.41
|
|
||||||
./dash_using_fullcalendar-0.1.0.tar.gz
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
"""
|
|
||||||
This app creates a collapsible, responsive sidebar layout with
|
|
||||||
dash-bootstrap-components and some custom css with media queries.
|
|
||||||
|
|
||||||
When the screen is small, the sidebar moved to the top of the page, and the
|
|
||||||
links get hidden in a collapse element. We use a callback to toggle the
|
|
||||||
collapse when on a small screen, and the custom CSS to hide the toggle, and
|
|
||||||
force the collapse to stay open when the screen is large.
|
|
||||||
|
|
||||||
dcc.Location is used to track the current location, a callback uses the current
|
|
||||||
location to render the appropriate page content. The active prop of each
|
|
||||||
NavLink is set automatically according to the current pathname. To use this
|
|
||||||
feature you must install dash-bootstrap-components >= 0.11.0.
|
|
||||||
|
|
||||||
For more details on building multi-page Dash applications, check out the Dash
|
|
||||||
documentation: https://dash.plotly.com/urls
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
sys.path.append('/workspace')
|
|
||||||
import dash
|
|
||||||
import dash_bootstrap_components as dbc
|
|
||||||
from dash import Input, Output, State, dcc, html, page_container
|
|
||||||
from dash_iconify import DashIconify
|
|
||||||
# import callbacks.ui_callbacks
|
|
||||||
import dashboard.callbacks.appointments_callbacks
|
|
||||||
import dashboard.callbacks.appointment_modal_callbacks
|
|
||||||
import dash_mantine_components as dmc
|
|
||||||
|
|
||||||
app = dash.Dash(
|
|
||||||
external_stylesheets=[dbc.themes.BOOTSTRAP],
|
|
||||||
# these meta_tags ensure content is scaled correctly on different devices
|
|
||||||
# see: https://www.w3schools.com/css/css_rwd_viewport.asp for more
|
|
||||||
meta_tags=[{"name": "viewport", "content": "width=device-width, initial-scale=1"}],
|
|
||||||
use_pages=True,
|
|
||||||
suppress_callback_exceptions=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
nav_items = [
|
|
||||||
{"label": "Übersicht", "href": "/overview", "icon": "mdi:view-dashboard"},
|
|
||||||
{"label": "Termine", "href": "/appointments","icon": "mdi:calendar"},
|
|
||||||
{"label": "Bildschirme", "href": "/clients", "icon": "mdi:monitor"},
|
|
||||||
{"label": "Einstellungen","href": "/settings", "icon": "mdi:cog"},
|
|
||||||
{"label": "Benutzer", "href": "/users", "icon": "mdi:account"},
|
|
||||||
]
|
|
||||||
|
|
||||||
nav_links = []
|
|
||||||
|
|
||||||
for item in nav_items:
|
|
||||||
# Create a NavLink for each item
|
|
||||||
link_id = {"type": "nav-item", "index": item["label"]}
|
|
||||||
nav_link = dbc.NavLink(
|
|
||||||
[
|
|
||||||
DashIconify(icon=item["icon"], width=24),
|
|
||||||
html.Span(item["label"], className="ms-2 sidebar-label"),
|
|
||||||
],
|
|
||||||
href=item["href"],
|
|
||||||
active="exact",
|
|
||||||
className="sidebar-item",
|
|
||||||
id=link_id,
|
|
||||||
)
|
|
||||||
nav_links.append(
|
|
||||||
html.Div(
|
|
||||||
children=nav_link,
|
|
||||||
className="nav-item-container"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# we use the Row and Col components to construct the sidebar header
|
|
||||||
# it consists of a title, and a toggle, the latter is hidden on large screens
|
|
||||||
sidebar_header = dbc.Row(
|
|
||||||
[
|
|
||||||
dbc.Col(html.H2("Sidebar", className="display-4")),
|
|
||||||
dbc.Col(
|
|
||||||
[
|
|
||||||
html.Button(
|
|
||||||
# use the Bootstrap navbar-toggler classes to style
|
|
||||||
html.Span(className="navbar-toggler-icon"),
|
|
||||||
className="navbar-toggler",
|
|
||||||
# the navbar-toggler classes don't set color
|
|
||||||
style={
|
|
||||||
"color": "rgba(0,0,0,.5)",
|
|
||||||
"border-color": "rgba(0,0,0,.1)",
|
|
||||||
},
|
|
||||||
id="navbar-toggle",
|
|
||||||
),
|
|
||||||
html.Button(
|
|
||||||
# use the Bootstrap navbar-toggler classes to style
|
|
||||||
html.Span(className="navbar-toggler-icon"),
|
|
||||||
className="navbar-toggler",
|
|
||||||
# the navbar-toggler classes don't set color
|
|
||||||
style={
|
|
||||||
"color": "rgba(0,0,0,.5)",
|
|
||||||
"border-color": "rgba(0,0,0,.1)",
|
|
||||||
},
|
|
||||||
id="sidebar-toggle",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
# the column containing the toggle will be only as wide as the
|
|
||||||
# toggle, resulting in the toggle being right aligned
|
|
||||||
width="auto",
|
|
||||||
# vertically align the toggle in the center
|
|
||||||
align="center",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
sidebar = html.Div(
|
|
||||||
[
|
|
||||||
sidebar_header,
|
|
||||||
# we wrap the horizontal rule and short blurb in a div that can be
|
|
||||||
# hidden on a small screen
|
|
||||||
html.Div(
|
|
||||||
[
|
|
||||||
html.Hr(),
|
|
||||||
html.P(
|
|
||||||
"A responsive sidebar layout with collapsible navigation " "links.",
|
|
||||||
className="lead",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
id="blurb",
|
|
||||||
),
|
|
||||||
# use the Collapse component to animate hiding / revealing links
|
|
||||||
dbc.Collapse(
|
|
||||||
dbc.Nav(
|
|
||||||
nav_links, # <-- Korrigiert: keine zusätzliche Liste
|
|
||||||
vertical=True,
|
|
||||||
pills=True,
|
|
||||||
),
|
|
||||||
id="collapse",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
id="sidebar",
|
|
||||||
)
|
|
||||||
|
|
||||||
content = dmc.MantineProvider([
|
|
||||||
html.Div(
|
|
||||||
html.Div(page_container, className="page-content"),style={"flex": "1", "padding": "20px"}
|
|
||||||
)
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
app.layout = html.Div([dcc.Location(id="url"), sidebar, content])
|
|
||||||
|
|
||||||
|
|
||||||
# @app.callback(Output("page-content", "children"), [Input("url", "pathname")])
|
|
||||||
# def render_page_content(pathname):
|
|
||||||
# if pathname == "/":
|
|
||||||
# return html.P("This is the content of the home page!")
|
|
||||||
# elif pathname == "/page-1":
|
|
||||||
# return html.P("This is the content of page 1. Yay!")
|
|
||||||
# elif pathname == "/page-2":
|
|
||||||
# return html.P("Oh cool, this is page 2!")
|
|
||||||
# # If the user tries to reach a different page, return a 404 message
|
|
||||||
# return html.Div(
|
|
||||||
# [
|
|
||||||
# html.H1("404: Not found", className="text-danger"),
|
|
||||||
# html.Hr(),
|
|
||||||
# html.P(f"The pathname {pathname} was not recognised..."),
|
|
||||||
# ],
|
|
||||||
# className="p-3 bg-light rounded-3",
|
|
||||||
# )
|
|
||||||
|
|
||||||
|
|
||||||
@app.callback(
|
|
||||||
[Output("sidebar", "className"), Output("collapse", "is_open")],
|
|
||||||
[
|
|
||||||
Input("sidebar-toggle", "n_clicks"),
|
|
||||||
Input("navbar-toggle", "n_clicks"),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
State("sidebar", "className"),
|
|
||||||
State("collapse", "is_open"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def toggle_sidebar_and_collapse(sidebar_n, navbar_n, classname, is_open):
|
|
||||||
ctx = dash.callback_context
|
|
||||||
if not ctx.triggered:
|
|
||||||
return classname, is_open
|
|
||||||
trigger_id = ctx.triggered[0]["prop_id"].split(".")[0]
|
|
||||||
if trigger_id == "sidebar-toggle":
|
|
||||||
# Toggle sidebar collapse
|
|
||||||
if sidebar_n and classname == "":
|
|
||||||
return "collapsed", is_open
|
|
||||||
return "", is_open
|
|
||||||
elif trigger_id == "navbar-toggle":
|
|
||||||
# Toggle collapse
|
|
||||||
if navbar_n:
|
|
||||||
return classname, not is_open
|
|
||||||
return classname, is_open
|
|
||||||
return classname, is_open
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app.run(port=8888, debug=True)
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# dashboard/utils/auth.py
|
|
||||||
import bcrypt
|
|
||||||
|
|
||||||
def hash_password(plain_text: str) -> str:
|
|
||||||
return bcrypt.hashpw(plain_text.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
|
||||||
|
|
||||||
def check_password(plain_text: str, hashed: str) -> bool:
|
|
||||||
return bcrypt.checkpw(plain_text.encode("utf-8"), hashed.encode("utf-8"))
|
|
||||||
|
|
||||||
def get_user_role(username: str) -> str:
|
|
||||||
# Beispiel: aus der Datenbank auslesen (oder Hardcode während Dev-Phase)
|
|
||||||
pass
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import os
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from sqlalchemy import create_engine, text
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
# .env laden
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
# Datenbank-Zugangsdaten aus .env
|
|
||||||
DB_USER = os.getenv("DB_USER")
|
|
||||||
DB_PASSWORD = os.getenv("DB_PASSWORD")
|
|
||||||
DB_HOST = os.getenv("DB_HOST", "localhost")
|
|
||||||
DB_PORT = os.getenv("DB_PORT", "3306")
|
|
||||||
DB_NAME = os.getenv("DB_NAME")
|
|
||||||
|
|
||||||
# Pooling Parameter aus .env (optional mit Default-Werten)
|
|
||||||
POOL_SIZE = int(os.getenv("POOL_SIZE", 10))
|
|
||||||
MAX_OVERFLOW = int(os.getenv("MAX_OVERFLOW", 20))
|
|
||||||
POOL_TIMEOUT = int(os.getenv("POOL_TIMEOUT", 30))
|
|
||||||
POOL_RECYCLE = int(os.getenv("POOL_RECYCLE", 1800))
|
|
||||||
|
|
||||||
# Connection-String zusammenbauen
|
|
||||||
DATABASE_URL = (
|
|
||||||
f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Engine mit Pooling konfigurieren
|
|
||||||
engine = create_engine(
|
|
||||||
DATABASE_URL,
|
|
||||||
pool_size=POOL_SIZE,
|
|
||||||
max_overflow=MAX_OVERFLOW,
|
|
||||||
pool_timeout=POOL_TIMEOUT,
|
|
||||||
pool_recycle=POOL_RECYCLE,
|
|
||||||
echo=True, # für Debug, später False
|
|
||||||
)
|
|
||||||
|
|
||||||
# Session Factory
|
|
||||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
|
||||||
|
|
||||||
def get_session():
|
|
||||||
return SessionLocal()
|
|
||||||
|
|
||||||
def execute_query(query):
|
|
||||||
with engine.connect() as connection:
|
|
||||||
result = connection.execute(text(query))
|
|
||||||
return [dict(row) for row in result]
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
# dashboard/utils/mqtt_client.py
|
|
||||||
|
|
||||||
import os
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
import paho.mqtt.client as mqtt
|
|
||||||
import random
|
|
||||||
|
|
||||||
# 1. Laden der Umgebungsvariablen aus .env
|
|
||||||
load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), "..", ".env"))
|
|
||||||
|
|
||||||
# 2. Lese MQTT‐Einstellungen
|
|
||||||
MQTT_BROKER_HOST = os.getenv("MQTT_BROKER_HOST", "localhost")
|
|
||||||
MQTT_BROKER_PORT = int(os.getenv("MQTT_BROKER_PORT", "1883"))
|
|
||||||
MQTT_USERNAME = os.getenv("MQTT_USERNAME", None)
|
|
||||||
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD", None)
|
|
||||||
MQTT_KEEPALIVE = int(os.getenv("MQTT_KEEPALIVE", "60"))
|
|
||||||
base_id = os.getenv("MQTT_CLIENT_ID", "dash")
|
|
||||||
unique_part = f"{os.getpid()}_{random.randint(1000,9999)}"
|
|
||||||
MQTT_CLIENT_ID = f"{base_id}-{unique_part}"
|
|
||||||
|
|
||||||
# 3. Erstelle eine globale Client‐Instanz
|
|
||||||
client = mqtt.Client(client_id=MQTT_CLIENT_ID)
|
|
||||||
|
|
||||||
# Falls Nutzer/Passwort gesetzt sind, authentifizieren
|
|
||||||
if MQTT_USERNAME and MQTT_PASSWORD:
|
|
||||||
client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
|
|
||||||
|
|
||||||
|
|
||||||
# 4. Callback‐Stubs (kannst du bei Bedarf anpassen)
|
|
||||||
def _on_connect(client, userdata, flags, rc):
|
|
||||||
if rc == 0:
|
|
||||||
print(f"[mqtt_client.py] Erfolgreich mit MQTT‐Broker verbunden (Code {rc})")
|
|
||||||
else:
|
|
||||||
print(f"[mqtt_client.py] Verbindungsfehler, rc={rc}")
|
|
||||||
|
|
||||||
|
|
||||||
def _on_disconnect(client, userdata, rc):
|
|
||||||
print(f"[mqtt_client.py] Verbindung getrennt (rc={rc}). Versuche, neu zu verbinden …")
|
|
||||||
|
|
||||||
|
|
||||||
def _on_message(client, userdata, msg):
|
|
||||||
"""
|
|
||||||
Diese Callback‐Funktion wird aufgerufen, sobald eine Nachricht auf einem
|
|
||||||
Topic ankommt, auf das wir subscribed haben. Du kannst hier eine Queue
|
|
||||||
füllen oder direkt eine Datenbank‐Funktion aufrufen.
|
|
||||||
"""
|
|
||||||
topic = msg.topic
|
|
||||||
payload = msg.payload.decode("utf-8", errors="ignore")
|
|
||||||
print(f"[mqtt_client.py] Nachricht eingegangen – Topic: {topic}, Payload: {payload}")
|
|
||||||
# Beispiel: Wenn du Live‐Statusdaten in die Datenbank schreibst,
|
|
||||||
# könntest du hier utils/db.execute_non_query(...) aufrufen.
|
|
||||||
|
|
||||||
|
|
||||||
# 5. Setze die Callbacks
|
|
||||||
client.on_connect = _on_connect
|
|
||||||
client.on_disconnect = _on_disconnect
|
|
||||||
client.on_message = _on_message
|
|
||||||
|
|
||||||
|
|
||||||
def start_loop():
|
|
||||||
"""
|
|
||||||
Startet die Endlos‐Schleife, in der der Client auf eingehende
|
|
||||||
MQTT‐Nachrichten hört und automatisch reconnectet.
|
|
||||||
Muss idealerweise in einem eigenen Thread laufen, damit Dash‐Callbacks
|
|
||||||
nicht blockieren.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
client.connect(MQTT_BROKER_HOST, MQTT_BROKER_PORT, keepalive=MQTT_KEEPALIVE)
|
|
||||||
client.loop_start()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[mqtt_client.py] Konnte keine Verbindung zum MQTT‐Broker herstellen: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def stop_loop():
|
|
||||||
"""
|
|
||||||
Stoppt die MQTT‐Loop und trennt die Verbindung.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
client.loop_stop()
|
|
||||||
client.disconnect()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[mqtt_client.py] Fehler beim Stoppen der MQTT‐Schleife: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def publish(topic: str, payload: str, qos: int = 0, retain: bool = False) -> bool:
|
|
||||||
"""
|
|
||||||
Verschickt eine MQTT‐Nachricht:
|
|
||||||
- topic: z. B. "clients/{client_id}/control"
|
|
||||||
- payload: z. B. '{"command":"restart"}'
|
|
||||||
- qos: 0, 1 oder 2
|
|
||||||
- retain: True/False
|
|
||||||
Rückgabe: True, falls Veröffentlichung bestätigt wurde; sonst False.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = client.publish(topic, payload, qos=qos, retain=retain)
|
|
||||||
status = result.rc # 0=Erfolg, sonst Fehler
|
|
||||||
if status == mqtt.MQTT_ERR_SUCCESS:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print(f"[mqtt_client.py] Publish-Fehler für Topic {topic}, rc={status}")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[mqtt_client.py] Exception beim Publish: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def subscribe(topic: str, qos: int = 0) -> bool:
|
|
||||||
"""
|
|
||||||
Abonniert ein MQTT‐Topic, sodass _on_message gerufen wird, sobald Nachrichten
|
|
||||||
ankommen.
|
|
||||||
Rückgabe: True bei Erfolg, ansonsten False.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result, mid = client.subscribe(topic, qos=qos)
|
|
||||||
if result == mqtt.MQTT_ERR_SUCCESS:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print(f"[mqtt_client.py] Subscribe‐Fehler für Topic {topic}, rc={result}")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[mqtt_client.py] Exception beim Subscribe: {e}")
|
|
||||||
return False
|
|
||||||
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,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": [
|
"extends": [
|
||||||
"stylelint-config-standard",
|
"stylelint-config-standard"
|
||||||
"stylelint-config-tailwindcss"
|
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"at-rule-no-unknown": null
|
"at-rule-no-unknown": null
|
||||||
|
|||||||
@@ -1,39 +1,25 @@
|
|||||||
# ==========================================
|
# ==========================================
|
||||||
# dashboard/Dockerfile (Production)
|
# dashboard/Dockerfile (Production)
|
||||||
# ==========================================
|
# ==========================================
|
||||||
FROM node:lts-alpine AS builder
|
|
||||||
|
|
||||||
|
FROM node:20-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Kopiere package.json und Lockfile aus dem Build-Kontext (./dashboard)
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
COPY pnpm-lock.yaml* ./
|
|
||||||
|
|
||||||
# Install pnpm and dependencies
|
# Produktions-Abhängigkeiten installieren
|
||||||
RUN npm install -g pnpm
|
ENV NODE_ENV=production
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
# Copy source code
|
# Quellcode kopieren und builden
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build arguments
|
|
||||||
ARG NODE_ENV=production
|
|
||||||
ARG VITE_API_URL
|
ARG VITE_API_URL
|
||||||
|
ENV VITE_API_URL=${VITE_API_URL}
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
# Build the application
|
FROM nginx:1.25-alpine
|
||||||
RUN pnpm build
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
# Production stage with nginx
|
|
||||||
FROM nginx:alpine
|
|
||||||
|
|
||||||
# Copy built files to nginx
|
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
|
||||||
|
|
||||||
# Copy custom nginx config (optional)
|
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
# Start nginx
|
|
||||||
CMD ["nginx", " -g", "daemon off;"]
|
CMD ["nginx", " -g", "daemon off;"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
# ==========================================
|
# ==========================================
|
||||||
# dashboard/Dockerfile.dev (Development)
|
# dashboard/Dockerfile.dev (Development)
|
||||||
|
# 🔧 OPTIMIERT: Für schnelle Entwicklung mit Vite und npm
|
||||||
# ==========================================
|
# ==========================================
|
||||||
FROM node:lts-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
# Stelle sicher, dass benötigte Tools verfügbar sind (z. B. für wait-for-backend.sh)
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
|
# Setze Arbeitsverzeichnis direkt auf das Dashboard-Verzeichnis im Container
|
||||||
|
# (Der Build-Kontext ist ./dashboard, siehe docker-compose.override.yml)
|
||||||
WORKDIR /workspace/dashboard
|
WORKDIR /workspace/dashboard
|
||||||
|
|
||||||
# Install dependencies manager (pnpm optional, npm reicht für Compose-Setup)
|
# KOPIEREN: Nur package-Dateien relativ zum Build-Kontext (KEINE /workspace-Pfade)
|
||||||
# RUN npm install -g pnpm
|
# package*.json deckt sowohl package.json als auch package-lock.json ab, falls vorhanden
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install dependencies (nutze npm, da Compose "npm run dev" nutzt)
|
# Installation robust machen: npm ci erfordert package-lock.json; fallback auf npm install
|
||||||
RUN npm install
|
RUN if [ -f package-lock.json ]; then \
|
||||||
|
npm ci --legacy-peer-deps; \
|
||||||
|
else \
|
||||||
|
npm install --legacy-peer-deps; \
|
||||||
|
fi && \
|
||||||
|
npm cache clean --force
|
||||||
|
|
||||||
# Copy source code
|
EXPOSE 5173 9230
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Expose ports
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
|
||||||
EXPOSE 3000 9229
|
|
||||||
|
|
||||||
# Standard-Dev-Command (wird von Compose überschrieben)
|
|
||||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vite + React + TS</title>
|
<title>Infoscreen</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
4419
dashboard/package-lock.json
generated
4419
dashboard/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,22 +4,42 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0 --port 3000",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@syncfusion/ej2-react-buttons": "^30.1.37",
|
"@syncfusion/ej2-base": "^30.2.0",
|
||||||
"@syncfusion/ej2-react-calendars": "^30.1.37",
|
"@syncfusion/ej2-buttons": "^30.2.0",
|
||||||
"@syncfusion/ej2-react-dropdowns": "^30.1.37",
|
"@syncfusion/ej2-calendars": "^30.2.0",
|
||||||
"@syncfusion/ej2-react-filemanager": "^30.1.38",
|
"@syncfusion/ej2-dropdowns": "^30.2.0",
|
||||||
"@syncfusion/ej2-react-grids": "^30.1.37",
|
"@syncfusion/ej2-gantt": "^32.1.23",
|
||||||
"@syncfusion/ej2-react-inputs": "^30.1.38",
|
"@syncfusion/ej2-grids": "^30.2.0",
|
||||||
"@syncfusion/ej2-react-kanban": "^30.1.37",
|
"@syncfusion/ej2-icons": "^30.2.0",
|
||||||
"@syncfusion/ej2-react-notifications": "^30.1.37",
|
"@syncfusion/ej2-inputs": "^30.2.0",
|
||||||
"@syncfusion/ej2-react-popups": "^30.1.37",
|
"@syncfusion/ej2-kanban": "^30.2.0",
|
||||||
"@syncfusion/ej2-react-schedule": "^30.1.37",
|
"@syncfusion/ej2-layouts": "^30.2.0",
|
||||||
|
"@syncfusion/ej2-lists": "^30.2.0",
|
||||||
|
"@syncfusion/ej2-navigations": "^30.2.0",
|
||||||
|
"@syncfusion/ej2-notifications": "^30.2.0",
|
||||||
|
"@syncfusion/ej2-popups": "^30.2.0",
|
||||||
|
"@syncfusion/ej2-react-base": "^30.2.0",
|
||||||
|
"@syncfusion/ej2-react-buttons": "^30.2.0",
|
||||||
|
"@syncfusion/ej2-react-calendars": "^30.2.0",
|
||||||
|
"@syncfusion/ej2-react-dropdowns": "^30.2.0",
|
||||||
|
"@syncfusion/ej2-react-filemanager": "^30.2.0",
|
||||||
|
"@syncfusion/ej2-react-gantt": "^32.1.23",
|
||||||
|
"@syncfusion/ej2-react-grids": "^30.2.0",
|
||||||
|
"@syncfusion/ej2-react-inputs": "^30.2.0",
|
||||||
|
"@syncfusion/ej2-react-kanban": "^30.2.0",
|
||||||
|
"@syncfusion/ej2-react-layouts": "^30.2.0",
|
||||||
|
"@syncfusion/ej2-react-navigations": "^30.2.0",
|
||||||
|
"@syncfusion/ej2-react-notifications": "^30.2.0",
|
||||||
|
"@syncfusion/ej2-react-popups": "^30.2.0",
|
||||||
|
"@syncfusion/ej2-react-schedule": "^30.2.0",
|
||||||
|
"@syncfusion/ej2-react-splitbuttons": "^30.2.0",
|
||||||
|
"@syncfusion/ej2-splitbuttons": "^30.2.0",
|
||||||
"cldr-data": "^36.0.4",
|
"cldr-data": "^36.0.4",
|
||||||
"lucide-react": "^0.522.0",
|
"lucide-react": "^0.522.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
@@ -28,9 +48,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
@@ -49,8 +66,6 @@
|
|||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"stylelint": "^16.21.0",
|
"stylelint": "^16.21.0",
|
||||||
"stylelint-config-standard": "^38.0.0",
|
"stylelint-config-standard": "^38.0.0",
|
||||||
"stylelint-config-tailwindcss": "^1.0.0",
|
|
||||||
"tailwindcss": "^3.4.17",
|
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.30.1",
|
"typescript-eslint": "^8.30.1",
|
||||||
"vite": "^6.3.5"
|
"vite": "^6.3.5"
|
||||||
|
|||||||
2310
dashboard/pnpm-lock.yaml
generated
2310
dashboard/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 225 KiB |
191
dashboard/public/program-info.json
Normal file
191
dashboard/public/program-info.json
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
{
|
||||||
|
"appName": "Infoscreen-Management",
|
||||||
|
"version": "2026.1.0-alpha.16",
|
||||||
|
"copyright": "© 2026 Third-Age-Applications",
|
||||||
|
"supportContact": "support@third-age-applications.com",
|
||||||
|
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
|
||||||
|
"techStack": {
|
||||||
|
"Frontend": "React, Vite, TypeScript, Syncfusion UI Components (Material 3)",
|
||||||
|
"Backend": "Python (Flask), SQLAlchemy",
|
||||||
|
"Database": "MariaDB",
|
||||||
|
"Realtime": "Mosquitto (MQTT)",
|
||||||
|
"Containerization": "Docker"
|
||||||
|
},
|
||||||
|
"openSourceComponents": {
|
||||||
|
"frontend": [
|
||||||
|
{ "name": "React", "license": "MIT" },
|
||||||
|
{ "name": "Vite", "license": "MIT" },
|
||||||
|
{ "name": "Lucide Icons", "license": "ISC" },
|
||||||
|
{ "name": "Syncfusion UI Components", "license": "Kommerziell / Community" }
|
||||||
|
],
|
||||||
|
"backend": [
|
||||||
|
{ "name": "Flask", "license": "BSD" },
|
||||||
|
{ "name": "SQLAlchemy", "license": "MIT" },
|
||||||
|
{ "name": "Paho-MQTT", "license": "EPL/EDL" },
|
||||||
|
{ "name": "Alembic", "license": "MIT" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"changelog": [
|
||||||
|
{
|
||||||
|
"version": "2026.1.0-alpha.16",
|
||||||
|
"date": "2026-04-02",
|
||||||
|
"changes": [
|
||||||
|
"✅ Dashboard: Der Ferienstatus-Banner zeigt die aktive akademische Periode jetzt zuverlässig nach Hard-Refresh und beim Wechsel zwischen Dashboard und Einstellungen.",
|
||||||
|
"🧭 Navigation: Der Link vom Ferienstatus-Banner zu den Einstellungen bleibt stabil und funktioniert konsistent für Admin-Rollen.",
|
||||||
|
"🚀 Deployment: Akademische Perioden werden nach Initialisierung automatisch für das aktuelle Datum aktiviert (kein manueller Aktivierungsschritt direkt nach Rollout mehr nötig).",
|
||||||
|
"🔤 Sprache: Mehrere deutsche UI-Texte im Dashboard wurden auf korrekte Umlaute umgestellt (zum Beispiel für, prüfen, Vorfälle und Ausfälle)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2026.1.0-alpha.15",
|
||||||
|
"date": "2026-03-31",
|
||||||
|
"changes": [
|
||||||
|
"✨ Einstellungen: Ferienverwaltung pro akademischer Periode verbessert (Import/Anzeige an ausgewählte Periode gebunden).",
|
||||||
|
"➕ Ferienkalender: Manuelle Ferienpflege mit Erstellen, Bearbeiten und Löschen direkt im gleichen Bereich.",
|
||||||
|
"✅ Validierung: Ferien-Datumsbereiche werden bei Import und manueller Erfassung gegen die gewählte Periode geprüft.",
|
||||||
|
"🧠 Ferienlogik: Doppelte Einträge werden verhindert; identische Überschneidungen (Name+Region) werden automatisch zusammengeführt.",
|
||||||
|
"⚠️ Import: Konfliktfälle bei überlappenden, unterschiedlichen Feiertags-Identitäten werden übersichtlich ausgewiesen.",
|
||||||
|
"🎯 UX: Dateiauswahl im Ferien-Import zeigt den gewählten Dateinamen zuverlässig an.",
|
||||||
|
"🎨 UI: Ferien-Tab und Dialoge an die definierten Syncfusion-Designregeln angeglichen."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2026.1.0-alpha.14",
|
||||||
|
"date": "2026-01-28",
|
||||||
|
"changes": [
|
||||||
|
"✨ UI: Neue 'Ressourcen'-Seite mit Timeline-Ansicht zeigt aktive Events für alle Raumgruppen parallel.",
|
||||||
|
"📊 Ressourcen: Kompakte Zeitachsen-Darstellung.",
|
||||||
|
"🎯 Ressourcen: Zeigt aktuell laufende Events mit Typ, Titel und Zeitfenster in Echtzeit.",
|
||||||
|
"🔄 Ressourcen: Gruppensortierung anpassbar mit visueller Reihenfolgen-Verwaltung.",
|
||||||
|
"🎨 Ressourcen: Farbcodierte Event-Balken entsprechend dem Gruppen-Theme."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2025.1.0-alpha.13",
|
||||||
|
"date": "2025-12-29",
|
||||||
|
"changes": [
|
||||||
|
"👥 UI: Neue 'Benutzer'-Seite mit vollständiger Benutzerverwaltung (CRUD) für Admins und Superadmins.",
|
||||||
|
"🔐 Benutzer-Seite: Sortierbare Gitter-Tabelle mit Benutzer-ID, Benutzername und Rolle; 20 Einträge pro Seite.",
|
||||||
|
"📊 Benutzer-Seite: Statistik-Karten zeigen Gesamtanzahl, aktive und inaktive Benutzer.",
|
||||||
|
"➕ Benutzer-Seite: Dialog zum Erstellen neuer Benutzer (Benutzername, Passwort, Rolle, Status).",
|
||||||
|
"✏️ Benutzer-Seite: Dialog zum Bearbeiten von Benutzer-Details mit Schutz vor Selbst-Änderungen.",
|
||||||
|
"🔑 Benutzer-Seite: Dialog zum Zurücksetzen von Passwörtern durch Admins (ohne alte Passwort-Anfrage).",
|
||||||
|
"❌ Benutzer-Seite: Dialog zum Löschen von Benutzern (nur für Superadmins; verhindert Selbst-Löschung).",
|
||||||
|
"📋 Benutzer-Seite: Details-Modal zeigt Audit-Informationen (letzte Anmeldung, Passwort-Änderung, Abmeldungen).",
|
||||||
|
"🎨 Benutzer-Seite: Rollen-Abzeichen mit Farb-Kodierung (Benutzer: grau, Editor: blau, Admin: grün, Superadmin: rot).",
|
||||||
|
"🔒 Header-Menü: Neue 'Passwort ändern'-Option im Benutzer-Dropdown für Selbstbedienung (alle Benutzer).",
|
||||||
|
"🔐 Passwort-Dialog: Authentifizierung mit aktuellem Passwort erforderlich (min. 6 Zeichen für neues Passwort).",
|
||||||
|
"🎯 Rollenbasiert: Menu-Einträge werden basierend auf Benutzer-Rolle gefiltert (z.B. 'Benutzer' nur für Admin+)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"changes": [
|
||||||
|
"🎬 Client: Clients können jetzt Video-Events aus dem Terminplaner abspielen (Streaming mit Seek via Byte-Range).",
|
||||||
|
"🧭 Einstellungen: Neues verschachteltes Tab-Layout mit kontrollierter Tab-Auswahl (keine Sprünge in Unter-Tabs).",
|
||||||
|
"📅 Einstellungen › Akademischer Kalender: ‘Schulferien Import’ und ‘Liste’ zusammengeführt in ‘📥 Import & Liste’.",
|
||||||
|
"🗓️ Events-Modal: Video-Optionen erweitert (Autoplay, Loop, Lautstärke, Ton aus). Werte werden bei neuen Terminen aus System-Defaults initialisiert.",
|
||||||
|
"⚙️ Einstellungen › Events › Videos: Globale Defaults für Autoplay, Loop, Lautstärke und Mute (Keys: video_autoplay, video_loop, video_volume, video_muted)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2025.1.0-alpha.10",
|
||||||
|
"date": "2025-10-25",
|
||||||
|
"changes": [
|
||||||
|
"🎬 Client: Client kann jetzt Videos wiedergeben (Playback/UI surface) — Benutzerseitige Präsentation wurde ergänzt.",
|
||||||
|
"🧩 UI: Event-Modal ergänzt um Video-Auswahl und Wiedergabe-Optionen (Autoplay, Loop, Lautstärke).",
|
||||||
|
"📁 Medien-UI: FileManager erlaubt größere Uploads für Full-HD-Videos; Client-seitige Validierung begrenzt Videolänge auf 10 Minuten."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2025.1.0-alpha.9",
|
||||||
|
"date": "2025-10-19",
|
||||||
|
"changes": [
|
||||||
|
"🆕 Events: Darstellung für ‘WebUntis’ harmonisiert mit ‘Website’ (UI/representation).",
|
||||||
|
"🛠️ Einstellungen › Events: WebUntis verwendet jetzt die bestehende Supplement-Table-Einstellung (Settings UI updated)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2025.1.0-alpha.8",
|
||||||
|
"date": "2025-10-18",
|
||||||
|
"changes": [
|
||||||
|
"✨ Einstellungen › Events › Präsentationen: Neue UI-Felder für Slide-Show Intervall, Page-Progress und Auto-Progress.",
|
||||||
|
"️ UI: Event-Modal lädt Präsentations-Einstellungen aus Global-Defaults bzw. Event-Daten (behaviour surfaced in UI)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2025.1.0-alpha.7",
|
||||||
|
"date": "2025-10-16",
|
||||||
|
"changes": [
|
||||||
|
"✨ Einstellungen-Seite: Neues Tab-Layout (Syncfusion) mit rollenbasierter Sichtbarkeit.",
|
||||||
|
"🗓️ Einstellungen › Events: WebUntis/Vertretungsplan in Events-Tab (enable/preview in UI).",
|
||||||
|
"📅 UI: Akademische Periode kann in der Einstellungen-Seite direkt gesetzt werden."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2025.1.0-alpha.6",
|
||||||
|
"date": "2025-10-15",
|
||||||
|
"changes": [
|
||||||
|
"✨ UI: Benutzer-Menü (top-right) mit Name/Rolle und Einträgen 'Profil' und 'Abmelden'."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2025.1.0-alpha.5",
|
||||||
|
"date": "2025-10-14",
|
||||||
|
"changes": [
|
||||||
|
"✨ UI: Einheitlicher Lösch-Workflow für Termine mit benutzerfreundlichen Dialogen (Einzeltermin, Einzelinstanz, Serie).",
|
||||||
|
"🔧 Frontend: RecurrenceAlert/DeleteAlert werden abgefangen und durch eigene Dialoge ersetzt (Verbesserung der UX).",
|
||||||
|
"✅ Bugfix (UX): Keine doppelten oder verwirrenden Bestätigungsdialoge mehr beim Löschen von Serienterminen."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2025.1.0-alpha.4",
|
||||||
|
"date": "2025-10-11",
|
||||||
|
"changes": [
|
||||||
|
"🎨 Theme: Umstellung auf Syncfusion Material 3; zentrale CSS-Imports (UI theme update).",
|
||||||
|
"🧩 UI: Gruppenverwaltung ('infoscreen_groups') auf Syncfusion-Komponenten umgestellt.",
|
||||||
|
"🔔 UI: Vereinheitlichte Notifications / Toast-Texte für konsistente UX."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2025.1.0-alpha.3",
|
||||||
|
"date": "2025-09-21",
|
||||||
|
"changes": [
|
||||||
|
"🧭 UI: Periode-Auswahl (Syncfusion) neben Gruppenauswahl; kompakte Layout-Verbesserung.",
|
||||||
|
"✅ Anzeige: Abzeichen für vorhandenen Ferienplan + 'Ferien im Blick' Zähler (UI indicator).",
|
||||||
|
"📤 UI: Ferien-Upload (TXT/CSV) Benutzer-Workflow ergänzt."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2025.1.0-alpha.2",
|
||||||
|
"date": "2025-09-01",
|
||||||
|
"changes": [
|
||||||
|
"UI Fix: Fehler beim Umschalten der Ansicht auf der Medien-Seite behoben."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2025.1.0-alpha.1",
|
||||||
|
"date": "2025-08-30",
|
||||||
|
"changes": [
|
||||||
|
"🆕 UI: Programminfo-Seite mit dynamischen Daten, Build-Infos und Changelog.",
|
||||||
|
"✨ UI: Logout-Funktionalität (Frontend) implementiert.",
|
||||||
|
"🐛 UI Fix: Breite der Sidebar im eingeklappten Zustand korrigiert."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,35 +1,59 @@
|
|||||||
@import "../node_modules/@syncfusion/ej2-base/styles/material.css";
|
/* Removed legacy Syncfusion material theme imports; using material3 imports in main.tsx */
|
||||||
@import "../node_modules/@syncfusion/ej2-buttons/styles/material.css";
|
|
||||||
@import "../node_modules/@syncfusion/ej2-calendars/styles/material.css";
|
|
||||||
@import "../node_modules/@syncfusion/ej2-dropdowns/styles/material.css";
|
|
||||||
@import "../node_modules/@syncfusion/ej2-inputs/styles/material.css";
|
|
||||||
@import "../node_modules/@syncfusion/ej2-lists/styles/material.css";
|
|
||||||
@import "../node_modules/@syncfusion/ej2-navigations/styles/material.css";
|
|
||||||
@import "../node_modules/@syncfusion/ej2-popups/styles/material.css";
|
|
||||||
@import "../node_modules/@syncfusion/ej2-splitbuttons/styles/material.css";
|
|
||||||
@import "../node_modules/@syncfusion/ej2-react-schedule/styles/material.css";
|
|
||||||
@import "../node_modules/@syncfusion/ej2-kanban/styles/material.css";
|
|
||||||
@import "../node_modules/@syncfusion/ej2-notifications/styles/material.css";
|
|
||||||
@import "../node_modules/@syncfusion/ej2-react-filemanager/styles/material.css";
|
|
||||||
@import "../node_modules/@syncfusion/ej2-layouts/styles/material.css";
|
|
||||||
@import "../node_modules/@syncfusion/ej2-grids/styles/material.css";
|
|
||||||
@import "../node_modules/@syncfusion/ej2-icons/styles/material.css";
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: Inter, 'Segoe UI', Roboto, Arial, sans-serif;
|
font-family: Inter, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||||
|
overflow: hidden; /* Verhindert den Scrollbalken auf der obersten Ebene */
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--sidebar-bg: #e5d8c7;
|
--sidebar-bg: #e5d8c7;
|
||||||
--sidebar-fg: #78591c;
|
--sidebar-fg: #78591c;
|
||||||
--sidebar-border: #d6c3a6;
|
--sidebar-border: #d6c3a6;
|
||||||
|
--sidebar-text: #000;
|
||||||
|
--sidebar-hover-bg: #d1b89b;
|
||||||
|
--sidebar-hover-text: #000;
|
||||||
|
--sidebar-active-bg: #cda76b;
|
||||||
|
--sidebar-active-text: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Layout-Container für Sidebar und Content */
|
||||||
|
.layout-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh; /* Feste Höhe auf die des Viewports setzen */
|
||||||
|
overflow: hidden; /* Verhindert, dass der Scrollbalken den gesamten Container betrifft */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar fixieren, keine Scrollbalken, volle Höhe */
|
||||||
.sidebar-theme {
|
.sidebar-theme {
|
||||||
background-color: var(--sidebar-bg);
|
background-color: var(--sidebar-bg);
|
||||||
color: var(--sidebar-fg);
|
color: var(--sidebar-text);
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 10; /* Stellt sicher, dass die Sidebar über dem Inhalt ist */
|
||||||
|
height: 100vh; /* Volle Browser-Höhe */
|
||||||
|
min-height: 100vh; /* Mindesthöhe für volle Browser-Höhe */
|
||||||
|
max-height: 100vh; /* Maximale Höhe begrenzen */
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sicherstelle vertikale Anordnung der Navigation und Footer am Ende */
|
||||||
|
.sidebar-theme nav {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
flex: 1 1 auto !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
min-height: 0 !important; /* Ermöglicht Flex-Shrinking */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer-Bereich am unteren Ende fixieren */
|
||||||
|
.sidebar-theme > div:last-child {
|
||||||
|
margin-top: auto !important;
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
min-height: auto !important;
|
||||||
|
padding-bottom: 0.5rem !important; /* Zusätzlicher Abstand vom unteren Rand */
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-theme .sidebar-link {
|
.sidebar-theme .sidebar-link {
|
||||||
@@ -37,6 +61,9 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
display: flex !important;
|
||||||
|
width: 100% !important;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-theme .sidebar-logout {
|
.sidebar-theme .sidebar-logout {
|
||||||
@@ -45,24 +72,48 @@ body {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
|
display: flex !important;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-theme .sidebar-btn,
|
|
||||||
.sidebar-theme .sidebar-link,
|
|
||||||
.sidebar-theme .sidebar-logout {
|
.sidebar-link:hover,
|
||||||
background-color: var(--sidebar-bg);
|
.sidebar-logout:hover {
|
||||||
color: var(--sidebar-fg);
|
background-color: var(--sidebar-hover-bg);
|
||||||
transition: background 0.2s, color 0.2s;
|
color: var(--sidebar-hover-text);
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-theme .sidebar-btn:hover,
|
.sidebar-link.active {
|
||||||
.sidebar-theme .sidebar-link:hover,
|
background-color: var(--sidebar-active-bg);
|
||||||
.sidebar-theme .sidebar-logout:hover {
|
color: var(--sidebar-active-text);
|
||||||
background-color: var(--sidebar-fg);
|
font-weight: bold;
|
||||||
color: var(--sidebar-bg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === START: SYNCFUSION-KOMPATIBLES LAYOUT === */
|
||||||
|
|
||||||
|
/* Der Inhaltsbereich arbeitet mit Syncfusion's natürlichem Layout */
|
||||||
|
.content-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0; /* Verhindert Flex-Item-Overflow */
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-header {
|
||||||
|
flex-shrink: 0; /* Header soll nicht schrumpfen */
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
flex-grow: 1; /* Füllt den verbleibenden Platz */
|
||||||
|
overflow-y: auto; /* NUR dieser Bereich scrollt */
|
||||||
|
padding: 2rem;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === ENDE: SYNCFUSION-KOMPATIBLES LAYOUT === */
|
||||||
|
|
||||||
|
|
||||||
/* Kanban-Karten im Sidebar-Style */
|
/* Kanban-Karten im Sidebar-Style */
|
||||||
.e-kanban .e-card,
|
.e-kanban .e-card,
|
||||||
.e-kanban .e-card .e-card-content,
|
.e-kanban .e-card .e-card-content,
|
||||||
@@ -106,4 +157,104 @@ body {
|
|||||||
color: color-mix(in srgb, var(--sidebar-fg) 85%, #000 15%) !important;
|
color: color-mix(in srgb, var(--sidebar-fg) 85%, #000 15%) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Entferne den globalen Scrollbalken von .main-content! */
|
||||||
|
.main-content {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto; /* Wiederherstellen des ursprünglichen Scroll-Verhaltens */
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Entfernt - Syncfusion verwaltet das Layout selbst */
|
||||||
|
|
||||||
|
/* Grundlegende Sidebar-Styles - Syncfusion-kompatibel */
|
||||||
|
#sidebar .sidebar-link,
|
||||||
|
#sidebar .sidebar-logout {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar .sidebar-link svg,
|
||||||
|
#sidebar .sidebar-logout svg {
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text standardmäßig IMMER sichtbar */
|
||||||
|
#sidebar .sidebar-link .sidebar-text,
|
||||||
|
#sidebar .sidebar-logout .sidebar-text {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
display: inline-block !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
transition: opacity 0.3s, transform 0.3s !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar .sidebar-link:hover,
|
||||||
|
#sidebar .sidebar-logout:hover {
|
||||||
|
background-color: var(--sidebar-hover-bg) !important;
|
||||||
|
color: var(--sidebar-hover-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded state - Text sichtbar (Standard) */
|
||||||
|
#sidebar .sidebar-theme.expanded .sidebar-link,
|
||||||
|
#sidebar .sidebar-theme.expanded .sidebar-logout {
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
padding: 12px 24px !important;
|
||||||
|
gap: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar .sidebar-theme.expanded .sidebar-text {
|
||||||
|
display: inline-block !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar .sidebar-theme.expanded .sidebar-link svg,
|
||||||
|
#sidebar .sidebar-theme.expanded .sidebar-logout svg {
|
||||||
|
margin-right: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsed state - nur Icons */
|
||||||
|
#sidebar .sidebar-theme.collapsed .sidebar-link,
|
||||||
|
#sidebar .sidebar-theme.collapsed .sidebar-logout {
|
||||||
|
justify-content: center !important;
|
||||||
|
padding: 12px 8px !important;
|
||||||
|
gap: 0 !important;
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar .sidebar-theme.collapsed .sidebar-text {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar .sidebar-theme.collapsed .sidebar-link svg,
|
||||||
|
#sidebar .sidebar-theme.collapsed .sidebar-logout svg {
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Syncfusion TooltipComponent wird jetzt verwendet - CSS-Tooltips entfernt */
|
||||||
|
|
||||||
|
/* Logo und Versionsnummer im collapsed state ausblenden */
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-50%) translateX(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-50%) translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo und Versionsnummer im collapsed state ausblenden */
|
||||||
|
#sidebar .sidebar-theme.collapsed img {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar .sidebar-theme.collapsed .version-info {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { BrowserRouter as Router, Routes, Route, Link, Outlet } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Link, Outlet, useNavigate, Navigate } from 'react-router-dom';
|
||||||
|
import { SidebarComponent } from '@syncfusion/ej2-react-navigations';
|
||||||
|
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
|
||||||
|
import { DropDownButtonComponent } from '@syncfusion/ej2-react-splitbuttons';
|
||||||
|
import type { MenuEventArgs } from '@syncfusion/ej2-splitbuttons';
|
||||||
|
import { TooltipComponent, DialogComponent } from '@syncfusion/ej2-react-popups';
|
||||||
|
import { TextBoxComponent } from '@syncfusion/ej2-react-inputs';
|
||||||
import logo from './assets/logo.png';
|
import logo from './assets/logo.png';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
@@ -13,133 +19,27 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Monitor,
|
Monitor,
|
||||||
MonitorDotIcon,
|
MonitorDotIcon,
|
||||||
|
Activity,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Wrench,
|
||||||
|
Info,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ToastProvider } from './components/ToastProvider';
|
import { ToastProvider } from './components/ToastProvider';
|
||||||
|
|
||||||
const sidebarItems = [
|
const sidebarItems = [
|
||||||
{ name: 'Dashboard', path: '/', icon: LayoutDashboard },
|
{ name: 'Dashboard', path: '/', icon: LayoutDashboard, minRole: 'user' },
|
||||||
{ name: 'Termine', path: '/termine', icon: Calendar },
|
{ name: 'Termine', path: '/termine', icon: Calendar, minRole: 'user' },
|
||||||
{ name: 'Ressourcen', path: '/ressourcen', icon: Boxes },
|
{ name: 'Ressourcen', path: '/ressourcen', icon: Boxes, minRole: 'editor' },
|
||||||
{ name: 'Raumgruppen', path: '/infoscr_groups', icon: MonitorDotIcon },
|
{ name: 'Raumgruppen', path: '/infoscr_groups', icon: MonitorDotIcon, minRole: 'admin' },
|
||||||
{ name: 'Infoscreens', path: '/Infoscreens', icon: Monitor },
|
{ name: 'Infoscreen-Clients', path: '/clients', icon: Monitor, minRole: 'admin' },
|
||||||
{ name: 'Medien', path: '/medien', icon: Image },
|
{ name: 'Monitor-Dashboard', path: '/monitoring', icon: Activity, minRole: 'superadmin' },
|
||||||
{ name: 'Benutzer', path: '/benutzer', icon: User },
|
{ name: 'Erweiterungsmodus', path: '/setup', icon: Wrench, minRole: 'admin' },
|
||||||
{ name: 'Einstellungen', path: '/einstellungen', icon: Settings },
|
{ name: 'Medien', path: '/medien', icon: Image, minRole: 'editor' },
|
||||||
|
{ name: 'Benutzer', path: '/benutzer', icon: User, minRole: 'admin' },
|
||||||
|
{ name: 'Einstellungen', path: '/einstellungen', icon: Settings, minRole: 'admin' },
|
||||||
|
{ name: 'Programminfo', path: '/programminfo', icon: Info, minRole: 'user' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const Layout: React.FC = () => {
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen">
|
|
||||||
{/* Sidebar */}
|
|
||||||
<aside
|
|
||||||
className={`sidebar-theme flex flex-col transition-all duration-300 ${collapsed ? 'w-20' : 'w-30'}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="h-20 flex items-center justify-center border-b"
|
|
||||||
style={{ borderColor: 'var(--sidebar-border)' }}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={logo}
|
|
||||||
alt="Logo"
|
|
||||||
className="h-12"
|
|
||||||
style={{ display: collapsed ? 'none' : 'block' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="sidebar-btn p-2 focus:outline-none transition-colors"
|
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
|
||||||
aria-label={collapsed ? 'Sidebar ausklappen' : 'Sidebar einklappen'}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 20 }}>{collapsed ? '▶' : '◀'}</span>
|
|
||||||
</button>
|
|
||||||
<nav className="flex-1 mt-4">
|
|
||||||
{sidebarItems.map(item => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.path}
|
|
||||||
to={item.path}
|
|
||||||
className="sidebar-link flex items-center gap-3 px-6 py-3 transition-colors no-underline"
|
|
||||||
title={collapsed ? item.name : undefined}
|
|
||||||
>
|
|
||||||
<Icon size={22} />
|
|
||||||
{!collapsed && item.name}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
{/* Abmelden-Button immer ganz unten */}
|
|
||||||
<div className="mb-4 mt-auto">
|
|
||||||
<button
|
|
||||||
className="sidebar-logout flex items-center gap-3 px-6 py-3 w-full transition-colors no-underline"
|
|
||||||
title={collapsed ? 'Abmelden' : undefined}
|
|
||||||
onClick={() => {
|
|
||||||
// Hier ggf. Logout-Logik einfügen
|
|
||||||
window.location.href = '/logout';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LogOut size={22} />
|
|
||||||
{!collapsed && 'Abmelden'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="flex-1 flex flex-col">
|
|
||||||
{/* Header */}
|
|
||||||
<header
|
|
||||||
className="flex items-center px-8 shadow"
|
|
||||||
style={{
|
|
||||||
backgroundColor: '#e5d8c7',
|
|
||||||
color: '#78591c',
|
|
||||||
height: 'calc(48px + 20px)',
|
|
||||||
fontSize: '1.15rem',
|
|
||||||
fontFamily:
|
|
||||||
'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={logo}
|
|
||||||
alt="Logo"
|
|
||||||
className="h-12 mr-4"
|
|
||||||
style={{ marginTop: 10, marginBottom: 10 }}
|
|
||||||
/>
|
|
||||||
<span className="text-2xl font-bold mr-8">Infoscreen-Management</span>
|
|
||||||
<span className="ml-auto" style={{ color: '#78591c' }}>
|
|
||||||
[Organisationsname]
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<main className="flex-1 p-8 bg-gray-100">
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const App: React.FC = () => (
|
|
||||||
<Router>
|
|
||||||
<ToastProvider>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<Layout />}>
|
|
||||||
<Route index element={<Dashboard />} />
|
|
||||||
<Route path="termine" element={<Appointments />} />
|
|
||||||
<Route path="ressourcen" element={<Ressourcen />} />
|
|
||||||
<Route path="Infoscreens" element={<Infoscreens />} />
|
|
||||||
<Route path="infoscr_groups" element={<Infoscreen_groups />} />
|
|
||||||
<Route path="medien" element={<Media />} />
|
|
||||||
<Route path="benutzer" element={<Benutzer />} />
|
|
||||||
<Route path="einstellungen" element={<Einstellungen />} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
</ToastProvider>
|
|
||||||
</Router>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
|
|
||||||
// Dummy Components (können in eigene Dateien ausgelagert werden)
|
// Dummy Components (können in eigene Dateien ausgelagert werden)
|
||||||
import Dashboard from './dashboard';
|
import Dashboard from './dashboard';
|
||||||
import Appointments from './appointments';
|
import Appointments from './appointments';
|
||||||
@@ -147,5 +47,491 @@ import Ressourcen from './ressourcen';
|
|||||||
import Infoscreens from './clients';
|
import Infoscreens from './clients';
|
||||||
import Infoscreen_groups from './infoscreen_groups';
|
import Infoscreen_groups from './infoscreen_groups';
|
||||||
import Media from './media';
|
import Media from './media';
|
||||||
import Benutzer from './benutzer';
|
import Benutzer from './users';
|
||||||
import Einstellungen from './einstellungen';
|
import Einstellungen from './settings';
|
||||||
|
import SetupMode from './SetupMode';
|
||||||
|
import Programminfo from './programminfo';
|
||||||
|
import MonitoringDashboard from './monitoring';
|
||||||
|
import Logout from './logout';
|
||||||
|
import Login from './login';
|
||||||
|
import { useAuth } from './useAuth';
|
||||||
|
import { changePassword } from './apiAuth';
|
||||||
|
import { useToast } from './components/ToastProvider';
|
||||||
|
|
||||||
|
// ENV aus .env holen (Platzhalter, im echten Projekt über process.env oder API)
|
||||||
|
// const ENV = import.meta.env.VITE_ENV || 'development';
|
||||||
|
|
||||||
|
const Layout: React.FC = () => {
|
||||||
|
const [version, setVersion] = useState('');
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
const [organizationName, setOrganizationName] = useState('');
|
||||||
|
let sidebarRef: SidebarComponent | null;
|
||||||
|
const { user } = useAuth();
|
||||||
|
const toast = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Change password dialog state
|
||||||
|
const [showPwdDialog, setShowPwdDialog] = useState(false);
|
||||||
|
const [pwdCurrent, setPwdCurrent] = useState('');
|
||||||
|
const [pwdNew, setPwdNew] = useState('');
|
||||||
|
const [pwdConfirm, setPwdConfirm] = useState('');
|
||||||
|
const [pwdBusy, setPwdBusy] = useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetch('/program-info.json')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => setVersion(data.version))
|
||||||
|
.catch(err => console.error('Failed to load version info:', err));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load organization name
|
||||||
|
React.useEffect(() => {
|
||||||
|
const loadOrgName = async () => {
|
||||||
|
try {
|
||||||
|
const { getOrganizationName } = await import('./apiSystemSettings');
|
||||||
|
const data = await getOrganizationName();
|
||||||
|
setOrganizationName(data.name || '');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load organization name:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadOrgName();
|
||||||
|
|
||||||
|
// Listen for organization name updates from Settings page
|
||||||
|
const handleUpdate = () => loadOrgName();
|
||||||
|
window.addEventListener('organizationNameUpdated', handleUpdate);
|
||||||
|
return () => window.removeEventListener('organizationNameUpdated', handleUpdate);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
if (sidebarRef) {
|
||||||
|
sidebarRef.toggle();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSidebarChange = () => {
|
||||||
|
// Syncfusion unterscheidet zwischen isOpen (true/false) und dem Dock-Modus
|
||||||
|
// Im Dock-Modus ist isOpen=true, aber die Sidebar ist kollabiert
|
||||||
|
const sidebar = sidebarRef?.element;
|
||||||
|
if (sidebar) {
|
||||||
|
const currentWidth = sidebar.style.width;
|
||||||
|
const newCollapsedState = currentWidth === '60px' || currentWidth.includes('60');
|
||||||
|
|
||||||
|
setIsCollapsed(newCollapsedState);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitPasswordChange = async () => {
|
||||||
|
if (!pwdCurrent || !pwdNew || !pwdConfirm) {
|
||||||
|
toast.show({ content: 'Bitte alle Felder ausfüllen', cssClass: 'e-toast-warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pwdNew.length < 6) {
|
||||||
|
toast.show({ content: 'Neues Passwort muss mindestens 6 Zeichen haben', cssClass: 'e-toast-warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pwdNew !== pwdConfirm) {
|
||||||
|
toast.show({ content: 'Passwörter stimmen nicht überein', cssClass: 'e-toast-warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPwdBusy(true);
|
||||||
|
try {
|
||||||
|
await changePassword(pwdCurrent, pwdNew);
|
||||||
|
toast.show({ content: 'Passwort erfolgreich geändert', cssClass: 'e-toast-success' });
|
||||||
|
setShowPwdDialog(false);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Fehler beim Ändern des Passworts';
|
||||||
|
toast.show({ content: msg, cssClass: 'e-toast-danger' });
|
||||||
|
} finally {
|
||||||
|
setPwdBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sidebarTemplate = () => (
|
||||||
|
<div
|
||||||
|
className={`sidebar-theme ${isCollapsed ? 'collapsed' : 'expanded'}`}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100vh',
|
||||||
|
minHeight: '100vh',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--sidebar-border)',
|
||||||
|
height: '68px',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderBottom: '1px solid var(--sidebar-border)',
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={logo}
|
||||||
|
alt="Logo"
|
||||||
|
style={{
|
||||||
|
height: '64px',
|
||||||
|
maxHeight: '60px',
|
||||||
|
display: 'block',
|
||||||
|
margin: '0 auto',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<nav
|
||||||
|
style={{
|
||||||
|
flex: '1 1 auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
marginTop: '1rem',
|
||||||
|
overflowY: 'auto',
|
||||||
|
minHeight: 0, // Wichtig für Flex-Shrinking
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sidebarItems
|
||||||
|
.filter(item => {
|
||||||
|
// Only show items the current user is allowed to see
|
||||||
|
if (!user) return false;
|
||||||
|
const roleHierarchy = ['user', 'editor', 'admin', 'superadmin'];
|
||||||
|
const userRoleIndex = roleHierarchy.indexOf(user.role);
|
||||||
|
const itemRoleIndex = roleHierarchy.indexOf(item.minRole || 'user');
|
||||||
|
return userRoleIndex >= itemRoleIndex;
|
||||||
|
})
|
||||||
|
.map(item => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const linkContent = (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className="sidebar-link no-underline w-full"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '12px 24px',
|
||||||
|
transition: 'background 0.2s, color 0.2s, justify-content 0.3s',
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'var(--sidebar-fg)',
|
||||||
|
backgroundColor: 'var(--sidebar-bg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={22} style={{ flexShrink: 0, marginRight: 0 }} />
|
||||||
|
<span className="sidebar-text" style={{ marginLeft: 0, transition: 'opacity 0.3s' }}>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Syncfusion Tooltip nur im collapsed state
|
||||||
|
return isCollapsed ? (
|
||||||
|
<TooltipComponent
|
||||||
|
key={item.path}
|
||||||
|
content={item.name}
|
||||||
|
position="RightCenter"
|
||||||
|
opensOn="Hover"
|
||||||
|
showTipPointer={true}
|
||||||
|
animation={{
|
||||||
|
open: { effect: 'FadeIn', duration: 200 },
|
||||||
|
close: { effect: 'FadeOut', duration: 200 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{linkContent}
|
||||||
|
</TooltipComponent>
|
||||||
|
) : (
|
||||||
|
linkContent
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
marginTop: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const logoutContent = (
|
||||||
|
<Link
|
||||||
|
to="/logout"
|
||||||
|
className="sidebar-logout no-underline w-full"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '12px 24px',
|
||||||
|
transition: 'background 0.2s, color 0.2s, justify-content 0.3s',
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'var(--sidebar-fg)',
|
||||||
|
backgroundColor: 'var(--sidebar-bg)',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '1.15rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogOut size={22} style={{ flexShrink: 0, marginRight: 0 }} />
|
||||||
|
<span className="sidebar-text" style={{ marginLeft: 0, transition: 'opacity 0.3s' }}>
|
||||||
|
Abmelden
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Syncfusion Tooltip nur im collapsed state
|
||||||
|
return isCollapsed ? (
|
||||||
|
<TooltipComponent
|
||||||
|
content="Abmelden"
|
||||||
|
position="RightCenter"
|
||||||
|
opensOn="Hover"
|
||||||
|
showTipPointer={true}
|
||||||
|
animation={{
|
||||||
|
open: { effect: 'FadeIn', duration: 200 },
|
||||||
|
close: { effect: 'FadeOut', duration: 200 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{logoutContent}
|
||||||
|
</TooltipComponent>
|
||||||
|
) : (
|
||||||
|
logoutContent
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{version && (
|
||||||
|
<div
|
||||||
|
className="version-info px-6 py-2 text-xs text-center opacity-70 border-t"
|
||||||
|
style={{ borderColor: 'var(--sidebar-border)' }}
|
||||||
|
>
|
||||||
|
Version {version}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="layout-container">
|
||||||
|
<SidebarComponent
|
||||||
|
id="sidebar"
|
||||||
|
ref={(sidebar: SidebarComponent | null) => {
|
||||||
|
sidebarRef = sidebar;
|
||||||
|
}}
|
||||||
|
width="256px"
|
||||||
|
target=".layout-container"
|
||||||
|
isOpen={true}
|
||||||
|
closeOnDocumentClick={false}
|
||||||
|
enableGestures={false}
|
||||||
|
type="Auto"
|
||||||
|
enableDock={true}
|
||||||
|
dockSize="60px"
|
||||||
|
change={onSidebarChange}
|
||||||
|
>
|
||||||
|
{sidebarTemplate()}
|
||||||
|
</SidebarComponent>
|
||||||
|
|
||||||
|
<div className="content-area">
|
||||||
|
<header
|
||||||
|
className="content-header flex items-center shadow"
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#e5d8c7',
|
||||||
|
color: '#78591c',
|
||||||
|
height: '68px', // Exakt gleiche Höhe wie Sidebar-Header
|
||||||
|
fontSize: '1.15rem',
|
||||||
|
fontFamily:
|
||||||
|
'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif',
|
||||||
|
margin: 0,
|
||||||
|
padding: '0 2rem 0 0', // Nur rechts Padding, links kein Padding
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ButtonComponent
|
||||||
|
cssClass="e-inherit"
|
||||||
|
iconCss="e-icons e-menu"
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
isToggle={true}
|
||||||
|
style={{
|
||||||
|
margin: '0 1rem 0 0', // Nur rechts Margin für Abstand zum Logo
|
||||||
|
padding: '8px 12px',
|
||||||
|
minWidth: '44px',
|
||||||
|
height: '44px',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<img src={logo} alt="Logo" className="h-16 mr-4" style={{ maxHeight: '60px' }} />
|
||||||
|
<span className="text-2xl font-bold mr-8" style={{ color: '#78591c' }}>
|
||||||
|
Infoscreen-Management
|
||||||
|
</span>
|
||||||
|
<div style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
{organizationName && (
|
||||||
|
<span className="text-lg font-medium" style={{ color: '#78591c' }}>
|
||||||
|
{organizationName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{user && (
|
||||||
|
<DropDownButtonComponent
|
||||||
|
items={[
|
||||||
|
{ text: 'Passwort ändern', id: 'change-password', iconCss: 'e-icons e-lock' },
|
||||||
|
{ separator: true },
|
||||||
|
{ text: 'Abmelden', id: 'logout', iconCss: 'e-icons e-logout' },
|
||||||
|
]}
|
||||||
|
select={(args: MenuEventArgs) => {
|
||||||
|
if (args.item.id === 'change-password') {
|
||||||
|
setPwdCurrent('');
|
||||||
|
setPwdNew('');
|
||||||
|
setPwdConfirm('');
|
||||||
|
setShowPwdDialog(true);
|
||||||
|
} else if (args.item.id === 'logout') {
|
||||||
|
navigate('/logout');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
cssClass="e-inherit"
|
||||||
|
>
|
||||||
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<User size={18} />
|
||||||
|
<span style={{ fontWeight: 600 }}>{user.username}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
opacity: 0.85,
|
||||||
|
border: '1px solid rgba(120, 89, 28, 0.25)',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '2px 6px',
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.6)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</DropDownButtonComponent>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<DialogComponent
|
||||||
|
isModal={true}
|
||||||
|
visible={showPwdDialog}
|
||||||
|
width="480px"
|
||||||
|
header="Passwort ändern"
|
||||||
|
showCloseIcon={true}
|
||||||
|
close={() => setShowPwdDialog(false)}
|
||||||
|
footerTemplate={() => (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<ButtonComponent cssClass="e-flat" onClick={() => setShowPwdDialog(false)} disabled={pwdBusy}>
|
||||||
|
Abbrechen
|
||||||
|
</ButtonComponent>
|
||||||
|
<ButtonComponent cssClass="e-primary" onClick={submitPasswordChange} disabled={pwdBusy}>
|
||||||
|
{pwdBusy ? 'Speichere...' : 'Speichern'}
|
||||||
|
</ButtonComponent>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: 6, fontWeight: 500 }}>Aktuelles Passwort *</label>
|
||||||
|
<TextBoxComponent
|
||||||
|
type="password"
|
||||||
|
placeholder="Aktuelles Passwort"
|
||||||
|
value={pwdCurrent}
|
||||||
|
input={(e: { value?: string }) => setPwdCurrent(e.value ?? '')}
|
||||||
|
disabled={pwdBusy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: 6, fontWeight: 500 }}>Neues Passwort *</label>
|
||||||
|
<TextBoxComponent
|
||||||
|
type="password"
|
||||||
|
placeholder="Mindestens 6 Zeichen"
|
||||||
|
value={pwdNew}
|
||||||
|
input={(e: { value?: string }) => setPwdNew(e.value ?? '')}
|
||||||
|
disabled={pwdBusy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: 6, fontWeight: 500 }}>Neues Passwort bestätigen *</label>
|
||||||
|
<TextBoxComponent
|
||||||
|
type="password"
|
||||||
|
placeholder="Wiederholen"
|
||||||
|
value={pwdConfirm}
|
||||||
|
input={(e: { value?: string }) => setPwdConfirm(e.value ?? '')}
|
||||||
|
disabled={pwdBusy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogComponent>
|
||||||
|
<main className="page-content">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const App: React.FC = () => {
|
||||||
|
// Automatische Navigation zu /clients bei leerer Beschreibung entfernt
|
||||||
|
|
||||||
|
const RequireAuth: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const { isAuthenticated, loading } = useAuth();
|
||||||
|
if (loading) return <div style={{ padding: 24 }}>Lade ...</div>;
|
||||||
|
if (!isAuthenticated) return <Login />;
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RequireSuperadmin: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const { isAuthenticated, loading, user } = useAuth();
|
||||||
|
if (loading) return <div style={{ padding: 24 }}>Lade ...</div>;
|
||||||
|
if (!isAuthenticated) return <Login />;
|
||||||
|
if (user?.role !== 'superadmin') return <Navigate to="/" replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<Layout />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="termine" element={<Appointments />} />
|
||||||
|
<Route path="ressourcen" element={<Ressourcen />} />
|
||||||
|
<Route path="infoscr_groups" element={<Infoscreen_groups />} />
|
||||||
|
<Route path="medien" element={<Media />} />
|
||||||
|
<Route path="benutzer" element={<Benutzer />} />
|
||||||
|
<Route path="einstellungen" element={<Einstellungen />} />
|
||||||
|
<Route path="clients" element={<Infoscreens />} />
|
||||||
|
<Route
|
||||||
|
path="monitoring"
|
||||||
|
element={
|
||||||
|
<RequireSuperadmin>
|
||||||
|
<MonitoringDashboard />
|
||||||
|
</RequireSuperadmin>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="setup" element={<SetupMode />} />
|
||||||
|
<Route path="programminfo" element={<Programminfo />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/logout" element={<Logout />} />
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
</Routes>
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AppWrapper: React.FC = () => (
|
||||||
|
<Router>
|
||||||
|
<App />
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AppWrapper;
|
||||||
|
|||||||
174
dashboard/src/SetupMode.tsx
Normal file
174
dashboard/src/SetupMode.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { fetchClientsWithoutDescription, setClientDescription } from './apiClients';
|
||||||
|
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
|
||||||
|
import { TextBoxComponent } from '@syncfusion/ej2-react-inputs';
|
||||||
|
import { GridComponent, ColumnsDirective, ColumnDirective } from '@syncfusion/ej2-react-grids';
|
||||||
|
import { DialogComponent } from '@syncfusion/ej2-react-popups';
|
||||||
|
import { useClientDelete } from './hooks/useClientDelete';
|
||||||
|
|
||||||
|
type Client = {
|
||||||
|
uuid: string;
|
||||||
|
hostname?: string;
|
||||||
|
ip_address?: string;
|
||||||
|
last_alive?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SetupMode: React.FC = () => {
|
||||||
|
const [clients, setClients] = useState<Client[]>([]);
|
||||||
|
const [descriptions, setDescriptions] = useState<Record<string, string>>({});
|
||||||
|
const [loading /* setLoading */] = useState(false);
|
||||||
|
const [inputActive, setInputActive] = useState(false);
|
||||||
|
|
||||||
|
// Lösch-Logik aus Hook (analog zu clients.tsx)
|
||||||
|
const { showDialog, deleteClientId, handleDelete, confirmDelete, cancelDelete } = useClientDelete(
|
||||||
|
async uuid => {
|
||||||
|
// Nach dem Löschen neu laden!
|
||||||
|
const updated = await fetchClientsWithoutDescription();
|
||||||
|
setClients(updated);
|
||||||
|
setDescriptions(prev => {
|
||||||
|
const copy = { ...prev };
|
||||||
|
delete copy[uuid];
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hilfsfunktion zum Vergleich der Clients
|
||||||
|
const isEqual = (a: Client[], b: Client[]) => {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
const aSorted = [...a].sort((x, y) => x.uuid.localeCompare(y.uuid));
|
||||||
|
const bSorted = [...b].sort((x, y) => x.uuid.localeCompare(y.uuid));
|
||||||
|
for (let i = 0; i < aSorted.length; i++) {
|
||||||
|
if (aSorted[i].uuid !== bSorted[i].uuid) return false;
|
||||||
|
if (aSorted[i].hostname !== bSorted[i].hostname) return false;
|
||||||
|
if (aSorted[i].ip_address !== bSorted[i].ip_address) return false;
|
||||||
|
if (aSorted[i].last_alive !== bSorted[i].last_alive) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let polling: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const fetchClients = () => {
|
||||||
|
if (inputActive) return;
|
||||||
|
fetchClientsWithoutDescription().then(list => {
|
||||||
|
setClients(prev => (isEqual(prev, list) ? prev : list));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchClients();
|
||||||
|
polling = setInterval(fetchClients, 5000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (polling) clearInterval(polling);
|
||||||
|
};
|
||||||
|
}, [inputActive]);
|
||||||
|
|
||||||
|
const handleDescriptionChange = (uuid: string, value: string) => {
|
||||||
|
setDescriptions(prev => ({ ...prev, [uuid]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = (uuid: string) => {
|
||||||
|
setClientDescription(uuid, descriptions[uuid] || '')
|
||||||
|
.then(() => {
|
||||||
|
setClients(prev => prev.filter(c => c.uuid !== uuid));
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Fehler beim Speichern der Beschreibung:', err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div>Lade neue Clients ...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Erweiterungsmodus: Neue Clients zuordnen</h2>
|
||||||
|
<GridComponent
|
||||||
|
dataSource={clients}
|
||||||
|
allowPaging={true}
|
||||||
|
pageSettings={{ pageSize: 10 }}
|
||||||
|
rowHeight={50}
|
||||||
|
width="100%"
|
||||||
|
allowTextWrap={false}
|
||||||
|
>
|
||||||
|
<ColumnsDirective>
|
||||||
|
<ColumnDirective field="uuid" headerText="UUID" width="180" />
|
||||||
|
<ColumnDirective field="hostname" headerText="Hostname" width="90" />
|
||||||
|
<ColumnDirective field="ip_address" headerText="IP" width="80" />
|
||||||
|
<ColumnDirective
|
||||||
|
headerText="Letzter Kontakt"
|
||||||
|
width="120"
|
||||||
|
template={(props: Client) => {
|
||||||
|
if (!props.last_alive) return '';
|
||||||
|
let iso = props.last_alive;
|
||||||
|
if (!iso.endsWith('Z')) iso += 'Z';
|
||||||
|
const date = new Date(iso);
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||||
|
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ColumnDirective
|
||||||
|
headerText="Beschreibung"
|
||||||
|
width="220"
|
||||||
|
template={(props: Client) => (
|
||||||
|
<TextBoxComponent
|
||||||
|
value={descriptions[props.uuid] || ''}
|
||||||
|
placeholder="Beschreibung eingeben"
|
||||||
|
change={e => handleDescriptionChange(props.uuid, e.value as string)}
|
||||||
|
focus={() => setInputActive(true)}
|
||||||
|
blur={() => setInputActive(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ColumnDirective
|
||||||
|
headerText="Aktion"
|
||||||
|
width="180"
|
||||||
|
template={(props: Client) => (
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<ButtonComponent
|
||||||
|
content="Speichern"
|
||||||
|
disabled={!descriptions[props.uuid]}
|
||||||
|
onClick={() => handleSave(props.uuid)}
|
||||||
|
/>
|
||||||
|
<ButtonComponent
|
||||||
|
content="Entfernen"
|
||||||
|
cssClass="e-danger"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(props.uuid);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</ColumnsDirective>
|
||||||
|
</GridComponent>
|
||||||
|
{clients.length === 0 && <div>Keine neuen Clients ohne Beschreibung.</div>}
|
||||||
|
|
||||||
|
{/* Syncfusion Dialog für Sicherheitsabfrage */}
|
||||||
|
{showDialog && deleteClientId && (
|
||||||
|
<DialogComponent
|
||||||
|
visible={showDialog}
|
||||||
|
header="Bestätigung"
|
||||||
|
content={(() => {
|
||||||
|
const client = clients.find(c => c.uuid === deleteClientId);
|
||||||
|
const hostname = client?.hostname ? ` (${client.hostname})` : '';
|
||||||
|
return client
|
||||||
|
? `Möchten Sie diesen Client${hostname} wirklich entfernen?`
|
||||||
|
: 'Client nicht gefunden.';
|
||||||
|
})()}
|
||||||
|
showCloseIcon={true}
|
||||||
|
width="400px"
|
||||||
|
buttons={[
|
||||||
|
{ click: confirmDelete, buttonModel: { content: 'Ja', isPrimary: true } },
|
||||||
|
{ click: cancelDelete, buttonModel: { content: 'Abbrechen' } },
|
||||||
|
]}
|
||||||
|
close={cancelDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SetupMode;
|
||||||
156
dashboard/src/apiAcademicPeriods.ts
Normal file
156
dashboard/src/apiAcademicPeriods.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
export type AcademicPeriod = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
displayName?: string | null;
|
||||||
|
startDate: string; // YYYY-MM-DD
|
||||||
|
endDate: string; // YYYY-MM-DD
|
||||||
|
periodType: 'schuljahr' | 'semester' | 'trimester';
|
||||||
|
isActive: boolean;
|
||||||
|
isArchived: boolean;
|
||||||
|
archivedAt?: string | null;
|
||||||
|
archivedBy?: number | null;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PeriodUsage = {
|
||||||
|
linked_events: number;
|
||||||
|
has_active_recurrence: boolean;
|
||||||
|
blockers: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeAcademicPeriod(period: any): AcademicPeriod {
|
||||||
|
return {
|
||||||
|
id: Number(period.id),
|
||||||
|
name: period.name,
|
||||||
|
displayName: period.displayName ?? period.display_name ?? null,
|
||||||
|
startDate: period.startDate ?? period.start_date,
|
||||||
|
endDate: period.endDate ?? period.end_date,
|
||||||
|
periodType: period.periodType ?? period.period_type,
|
||||||
|
isActive: Boolean(period.isActive ?? period.is_active),
|
||||||
|
isArchived: Boolean(period.isArchived ?? period.is_archived),
|
||||||
|
archivedAt: period.archivedAt ?? period.archived_at ?? null,
|
||||||
|
archivedBy: period.archivedBy ?? period.archived_by ?? null,
|
||||||
|
createdAt: period.createdAt ?? period.created_at,
|
||||||
|
updatedAt: period.updatedAt ?? period.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(url, { credentials: 'include', cache: 'no-store', ...init });
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
try {
|
||||||
|
const err = JSON.parse(text);
|
||||||
|
throw new Error(err.error || `HTTP ${res.status}`);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`HTTP ${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAcademicPeriodForDate(date: Date): Promise<AcademicPeriod | null> {
|
||||||
|
const iso = date.toISOString().slice(0, 10);
|
||||||
|
const { period } = await api<{ period: any | null }>(
|
||||||
|
`/api/academic_periods/for_date?date=${iso}`
|
||||||
|
);
|
||||||
|
return period ? normalizeAcademicPeriod(period) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAcademicPeriods(options?: {
|
||||||
|
includeArchived?: boolean;
|
||||||
|
archivedOnly?: boolean;
|
||||||
|
}): Promise<AcademicPeriod[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.includeArchived) {
|
||||||
|
params.set('includeArchived', '1');
|
||||||
|
}
|
||||||
|
if (options?.archivedOnly) {
|
||||||
|
params.set('archivedOnly', '1');
|
||||||
|
}
|
||||||
|
const query = params.toString();
|
||||||
|
const { periods } = await api<{ periods: any[] }>(
|
||||||
|
`/api/academic_periods${query ? `?${query}` : ''}`
|
||||||
|
);
|
||||||
|
return Array.isArray(periods) ? periods.map(normalizeAcademicPeriod) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAcademicPeriod(id: number): Promise<AcademicPeriod> {
|
||||||
|
const { period } = await api<{ period: any }>(`/api/academic_periods/${id}`);
|
||||||
|
return normalizeAcademicPeriod(period);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveAcademicPeriod(): Promise<AcademicPeriod | null> {
|
||||||
|
const { period } = await api<{ period: any | null }>(`/api/academic_periods/active`);
|
||||||
|
return period ? normalizeAcademicPeriod(period) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAcademicPeriod(payload: {
|
||||||
|
name: string;
|
||||||
|
displayName?: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
periodType: 'schuljahr' | 'semester' | 'trimester';
|
||||||
|
}): Promise<AcademicPeriod> {
|
||||||
|
const { period } = await api<{ period: any }>(`/api/academic_periods`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
return normalizeAcademicPeriod(period);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAcademicPeriod(
|
||||||
|
id: number,
|
||||||
|
payload: Partial<{
|
||||||
|
name: string;
|
||||||
|
displayName: string | null;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
periodType: 'schuljahr' | 'semester' | 'trimester';
|
||||||
|
}>
|
||||||
|
): Promise<AcademicPeriod> {
|
||||||
|
const { period } = await api<{ period: any }>(`/api/academic_periods/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
return normalizeAcademicPeriod(period);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setActiveAcademicPeriod(id: number): Promise<AcademicPeriod> {
|
||||||
|
const { period } = await api<{ period: any }>(`/api/academic_periods/${id}/activate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
return normalizeAcademicPeriod(period);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveAcademicPeriod(id: number): Promise<AcademicPeriod> {
|
||||||
|
const { period } = await api<{ period: any }>(`/api/academic_periods/${id}/archive`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
return normalizeAcademicPeriod(period);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreAcademicPeriod(id: number): Promise<AcademicPeriod> {
|
||||||
|
const { period } = await api<{ period: any }>(`/api/academic_periods/${id}/restore`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
return normalizeAcademicPeriod(period);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAcademicPeriodUsage(id: number): Promise<PeriodUsage> {
|
||||||
|
const { usage } = await api<{ usage: PeriodUsage }>(`/api/academic_periods/${id}/usage`);
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAcademicPeriod(id: number): Promise<void> {
|
||||||
|
await api(`/api/academic_periods/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
182
dashboard/src/apiAuth.ts
Normal file
182
dashboard/src/apiAuth.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* Authentication API client for the dashboard.
|
||||||
|
*
|
||||||
|
* Provides functions to interact with auth endpoints including login,
|
||||||
|
* logout, and fetching current user information.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: 'user' | 'editor' | 'admin' | 'superadmin';
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
message: string;
|
||||||
|
user: {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthCheckResponse {
|
||||||
|
authenticated: boolean;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change password for the currently authenticated user.
|
||||||
|
*/
|
||||||
|
export async function changePassword(currentPassword: string, newPassword: string): Promise<{ message: string }> {
|
||||||
|
const res = await fetch('/api/auth/change-password', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to change password');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as { message: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate a user with username and password.
|
||||||
|
*
|
||||||
|
* @param username - The user's username
|
||||||
|
* @param password - The user's password
|
||||||
|
* @returns Promise<LoginResponse>
|
||||||
|
* @throws Error if login fails
|
||||||
|
*/
|
||||||
|
export async function login(username: string, password: string): Promise<LoginResponse> {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include', // Important for session cookies
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || data.error) {
|
||||||
|
throw new Error(data.error || 'Login failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log out the current user.
|
||||||
|
*
|
||||||
|
* @returns Promise<void>
|
||||||
|
* @throws Error if logout fails
|
||||||
|
*/
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
const res = await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || data.error) {
|
||||||
|
throw new Error(data.error || 'Logout failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the current authenticated user's information.
|
||||||
|
*
|
||||||
|
* @returns Promise<User>
|
||||||
|
* @throws Error if not authenticated or request fails
|
||||||
|
*/
|
||||||
|
export async function fetchCurrentUser(): Promise<User> {
|
||||||
|
const res = await fetch('/api/auth/me', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || data.error) {
|
||||||
|
throw new Error(data.error || 'Failed to fetch current user');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as User;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick check if user is authenticated (lighter than fetchCurrentUser).
|
||||||
|
*
|
||||||
|
* @returns Promise<AuthCheckResponse>
|
||||||
|
*/
|
||||||
|
export async function checkAuth(): Promise<AuthCheckResponse> {
|
||||||
|
const res = await fetch('/api/auth/check', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Failed to check authentication status');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to check if a user has a specific role.
|
||||||
|
*
|
||||||
|
* @param user - The user object
|
||||||
|
* @param role - The role to check for
|
||||||
|
* @returns boolean
|
||||||
|
*/
|
||||||
|
export function hasRole(user: User | null, role: string): boolean {
|
||||||
|
if (!user) return false;
|
||||||
|
return user.role === role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to check if a user has any of the specified roles.
|
||||||
|
*
|
||||||
|
* @param user - The user object
|
||||||
|
* @param roles - Array of roles to check for
|
||||||
|
* @returns boolean
|
||||||
|
*/
|
||||||
|
export function hasAnyRole(user: User | null, roles: string[]): boolean {
|
||||||
|
if (!user) return false;
|
||||||
|
return roles.includes(user.role);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to check if user is superadmin.
|
||||||
|
*/
|
||||||
|
export function isSuperadmin(user: User | null): boolean {
|
||||||
|
return hasRole(user, 'superadmin');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to check if user is admin or higher.
|
||||||
|
*/
|
||||||
|
export function isAdminOrHigher(user: User | null): boolean {
|
||||||
|
return hasAnyRole(user, ['admin', 'superadmin']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to check if user is editor or higher.
|
||||||
|
*/
|
||||||
|
export function isEditorOrHigher(user: User | null): boolean {
|
||||||
|
return hasAnyRole(user, ['editor', 'admin', 'superadmin']);
|
||||||
|
}
|
||||||
113
dashboard/src/apiClientMonitoring.ts
Normal file
113
dashboard/src/apiClientMonitoring.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
export interface MonitoringLogEntry {
|
||||||
|
id: number;
|
||||||
|
timestamp: string | null;
|
||||||
|
level: 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | null;
|
||||||
|
message: string;
|
||||||
|
context: Record<string, unknown>;
|
||||||
|
client_uuid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitoringClient {
|
||||||
|
uuid: string;
|
||||||
|
hostname?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
ip?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
groupId?: number | null;
|
||||||
|
groupName?: string | null;
|
||||||
|
registrationTime?: string | null;
|
||||||
|
lastAlive?: string | null;
|
||||||
|
isAlive: boolean;
|
||||||
|
status: 'healthy' | 'warning' | 'critical' | 'offline';
|
||||||
|
currentEventId?: number | null;
|
||||||
|
currentProcess?: string | null;
|
||||||
|
processStatus?: string | null;
|
||||||
|
processPid?: number | null;
|
||||||
|
screenHealthStatus?: string | null;
|
||||||
|
lastScreenshotAnalyzed?: string | null;
|
||||||
|
lastScreenshotHash?: string | null;
|
||||||
|
latestScreenshotType?: 'periodic' | 'event_start' | 'event_stop' | null;
|
||||||
|
priorityScreenshotType?: 'event_start' | 'event_stop' | null;
|
||||||
|
priorityScreenshotReceivedAt?: string | null;
|
||||||
|
hasActivePriorityScreenshot?: boolean;
|
||||||
|
screenshotUrl: string;
|
||||||
|
logCounts24h: {
|
||||||
|
error: number;
|
||||||
|
warn: number;
|
||||||
|
info: number;
|
||||||
|
debug: number;
|
||||||
|
};
|
||||||
|
latestLog?: MonitoringLogEntry | null;
|
||||||
|
latestError?: MonitoringLogEntry | null;
|
||||||
|
mqttReconnectCount?: number | null;
|
||||||
|
mqttLastDisconnectAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitoringOverview {
|
||||||
|
summary: {
|
||||||
|
totalClients: number;
|
||||||
|
onlineClients: number;
|
||||||
|
offlineClients: number;
|
||||||
|
healthyClients: number;
|
||||||
|
warningClients: number;
|
||||||
|
criticalClients: number;
|
||||||
|
errorLogs: number;
|
||||||
|
warnLogs: number;
|
||||||
|
activePriorityScreenshots: number;
|
||||||
|
};
|
||||||
|
periodHours: number;
|
||||||
|
gracePeriodSeconds: number;
|
||||||
|
since: string;
|
||||||
|
timestamp: string;
|
||||||
|
clients: MonitoringClient[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientLogsResponse {
|
||||||
|
client_uuid: string;
|
||||||
|
logs: MonitoringLogEntry[];
|
||||||
|
count: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseJsonResponse<T>(response: Response, fallbackMessage: string): Promise<T> {
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || fallbackMessage);
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMonitoringOverview(hours = 24): Promise<MonitoringOverview> {
|
||||||
|
const response = await fetch(`/api/client-logs/monitoring-overview?hours=${hours}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
return parseJsonResponse<MonitoringOverview>(response, 'Fehler beim Laden der Monitoring-Übersicht');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRecentClientErrors(limit = 20): Promise<MonitoringLogEntry[]> {
|
||||||
|
const response = await fetch(`/api/client-logs/recent-errors?limit=${limit}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
const data = await parseJsonResponse<{ errors: MonitoringLogEntry[] }>(
|
||||||
|
response,
|
||||||
|
'Fehler beim Laden der letzten Fehler'
|
||||||
|
);
|
||||||
|
return data.errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchClientMonitoringLogs(
|
||||||
|
uuid: string,
|
||||||
|
options: { level?: string; limit?: number } = {}
|
||||||
|
): Promise<MonitoringLogEntry[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options.level && options.level !== 'ALL') {
|
||||||
|
params.set('level', options.level);
|
||||||
|
}
|
||||||
|
params.set('limit', String(options.limit ?? 100));
|
||||||
|
|
||||||
|
const response = await fetch(`/api/client-logs/${uuid}/logs?${params.toString()}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
const data = await parseJsonResponse<ClientLogsResponse>(response, 'Fehler beim Laden der Client-Logs');
|
||||||
|
return data.logs;
|
||||||
|
}
|
||||||
@@ -1,12 +1,92 @@
|
|||||||
// Funktion zum Laden der Clients von der API
|
|
||||||
|
|
||||||
export interface Client {
|
export interface Client {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
location: string;
|
hardware_token?: string;
|
||||||
hardware_hash: string;
|
ip?: string;
|
||||||
ip_address: string;
|
type?: string;
|
||||||
last_alive: string | null;
|
hostname?: string;
|
||||||
group_id: number; // <--- Dieses Feld ergänzen
|
os_version?: string;
|
||||||
|
software_version?: string;
|
||||||
|
macs?: string;
|
||||||
|
model?: string;
|
||||||
|
description?: string;
|
||||||
|
registration_time?: string;
|
||||||
|
last_alive?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
group_id?: number;
|
||||||
|
// Für Health-Status
|
||||||
|
is_alive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Group {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
created_at?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
clients: Client[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrashedClient {
|
||||||
|
uuid: string;
|
||||||
|
description?: string | null;
|
||||||
|
hostname?: string | null;
|
||||||
|
ip?: string | null;
|
||||||
|
group_id?: number | null;
|
||||||
|
is_alive: boolean;
|
||||||
|
process_status?: string | null;
|
||||||
|
screen_health_status?: string | null;
|
||||||
|
last_alive?: string | null;
|
||||||
|
crash_reason: 'process_crashed' | 'heartbeat_stale';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrashedClientsResponse {
|
||||||
|
crashed_count: number;
|
||||||
|
grace_period_seconds: number;
|
||||||
|
clients: CrashedClient[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceFailedClient {
|
||||||
|
uuid: string;
|
||||||
|
description?: string | null;
|
||||||
|
hostname?: string | null;
|
||||||
|
ip?: string | null;
|
||||||
|
group_id?: number | null;
|
||||||
|
is_alive: boolean;
|
||||||
|
last_alive?: string | null;
|
||||||
|
service_failed_at: string;
|
||||||
|
service_failed_unit?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceFailedClientsResponse {
|
||||||
|
service_failed_count: number;
|
||||||
|
clients: ServiceFailedClient[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientCommand {
|
||||||
|
commandId: string;
|
||||||
|
clientUuid: string;
|
||||||
|
action: 'reboot_host' | 'shutdown_host' | 'restart_app';
|
||||||
|
status: string;
|
||||||
|
reason?: string | null;
|
||||||
|
requestedBy?: number | null;
|
||||||
|
issuedAt?: string | null;
|
||||||
|
expiresAt?: string | null;
|
||||||
|
publishedAt?: string | null;
|
||||||
|
ackedAt?: string | null;
|
||||||
|
executionStartedAt?: string | null;
|
||||||
|
completedAt?: string | null;
|
||||||
|
failedAt?: string | null;
|
||||||
|
errorCode?: string | null;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
}
|
||||||
|
// Liefert alle Gruppen mit zugehörigen Clients
|
||||||
|
export async function fetchGroupsWithClients(): Promise<Group[]> {
|
||||||
|
const response = await fetch('/api/groups/with_clients');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Fehler beim Laden der Gruppen mit Clients');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchClients(): Promise<Client[]> {
|
export async function fetchClients(): Promise<Client[]> {
|
||||||
@@ -17,12 +97,119 @@ export async function fetchClients(): Promise<Client[]> {
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateClientGroup(clientIds: string[], groupName: string) {
|
export async function fetchClientsWithoutDescription(): Promise<Client[]> {
|
||||||
|
const response = await fetch('/api/clients/without_description');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Fehler beim Laden der Clients ohne Beschreibung');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setClientDescription(uuid: string, description: string) {
|
||||||
|
const res = await fetch(`/api/clients/${uuid}/description`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ description }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Fehler beim Setzen der Beschreibung');
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateClientGroup(clientIds: string[], groupId: number) {
|
||||||
const res = await fetch('/api/clients/group', {
|
const res = await fetch('/api/clients/group', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ client_ids: clientIds, group_name: groupName }),
|
body: JSON.stringify({ client_ids: clientIds, group_id: groupId }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Fehler beim Aktualisieren der Clients');
|
if (!res.ok) throw new Error('Fehler beim Aktualisieren der Clients');
|
||||||
return await res.json();
|
return await res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateClient(uuid: string, data: { description?: string; model?: string }) {
|
||||||
|
const res = await fetch(`/api/clients/${uuid}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Fehler beim Aktualisieren des Clients');
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restartClient(uuid: string, reason?: string): Promise<{ success: boolean; message?: string; command?: ClientCommand }> {
|
||||||
|
const response = await fetch(`/api/clients/${uuid}/restart`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ reason: reason || null }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Fehler beim Neustart des Clients');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shutdownClient(uuid: string, reason?: string): Promise<{ success: boolean; message?: string; command?: ClientCommand }> {
|
||||||
|
const response = await fetch(`/api/clients/${uuid}/shutdown`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ reason: reason || null }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Fehler beim Herunterfahren des Clients');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchClientCommandStatus(commandId: string): Promise<ClientCommand> {
|
||||||
|
const response = await fetch(`/api/clients/commands/${commandId}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Fehler beim Laden des Command-Status');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCrashedClients(): Promise<CrashedClientsResponse> {
|
||||||
|
const response = await fetch('/api/clients/crashed', { credentials: 'include' });
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || 'Fehler beim Laden der abgestürzten Clients');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchServiceFailedClients(): Promise<ServiceFailedClientsResponse> {
|
||||||
|
const response = await fetch('/api/clients/service_failed', { credentials: 'include' });
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || 'Fehler beim Laden der service_failed Clients');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearServiceFailed(uuid: string): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const response = await fetch(`/api/clients/${uuid}/clear_service_failed`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || 'Fehler beim Quittieren des service_failed Flags');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteClient(uuid: string) {
|
||||||
|
const res = await fetch(`/api/clients/${uuid}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Fehler beim Entfernen des Clients');
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMediaById(mediaId: number | string) {
|
||||||
|
const response = await fetch(`/api/eventmedia/${mediaId}`);
|
||||||
|
if (!response.ok) throw new Error('Fehler beim Laden der Mediainformationen');
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|||||||
31
dashboard/src/apiEventExceptions.ts
Normal file
31
dashboard/src/apiEventExceptions.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export interface EventException {
|
||||||
|
id?: number;
|
||||||
|
event_id: number;
|
||||||
|
exception_date: string; // YYYY-MM-DD
|
||||||
|
is_skipped: boolean;
|
||||||
|
override_title?: string;
|
||||||
|
override_description?: string;
|
||||||
|
override_start?: string;
|
||||||
|
override_end?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEventException(exception: Omit<EventException, 'id'>) {
|
||||||
|
const res = await fetch('/api/event_exceptions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(exception),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Erstellen der Ausnahme');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEventExceptions(eventId?: number) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (eventId) params.set('event_id', eventId.toString());
|
||||||
|
|
||||||
|
const res = await fetch(`/api/event_exceptions?${params.toString()}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Ausnahmen');
|
||||||
|
return data as EventException[];
|
||||||
|
}
|
||||||
@@ -8,18 +8,94 @@ export interface Event {
|
|||||||
extendedProps: Record<string, unknown>;
|
extendedProps: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchEvents(groupId: string) {
|
export async function fetchEvents(
|
||||||
const res = await fetch(`/api/events?group_id=${encodeURIComponent(groupId)}`);
|
groupId: string,
|
||||||
|
showInactive = false,
|
||||||
|
options?: { start?: Date; end?: Date; expand?: boolean }
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('group_id', groupId);
|
||||||
|
params.set('show_inactive', showInactive ? '1' : '0');
|
||||||
|
if (options?.start) params.set('start', options.start.toISOString());
|
||||||
|
if (options?.end) params.set('end', options.end.toISOString());
|
||||||
|
if (options?.expand) params.set('expand', options.expand ? '1' : '0');
|
||||||
|
const res = await fetch(`/api/events?${params.toString()}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Termine');
|
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Termine');
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteEvent(eventId: string) {
|
export async function fetchEventById(eventId: string) {
|
||||||
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}`, {
|
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden des Termins');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEvent(eventId: string, force: boolean = false) {
|
||||||
|
const url = force
|
||||||
|
? `/api/events/${encodeURIComponent(eventId)}?force=1`
|
||||||
|
: `/api/events/${encodeURIComponent(eventId)}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Löschen des Termins');
|
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Löschen des Termins');
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteEventOccurrence(eventId: string, occurrenceDate: string) {
|
||||||
|
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}/occurrences/${encodeURIComponent(occurrenceDate)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Löschen des Einzeltermins');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEventOccurrence(eventId: string, occurrenceDate: string, payload: UpdateEventPayload) {
|
||||||
|
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}/occurrences/${encodeURIComponent(occurrenceDate)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Aktualisieren des Einzeltermins');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateEventPayload {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEvent(eventId: string, payload: UpdateEventPayload) {
|
||||||
|
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Aktualisieren des Termins');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const detachEventOccurrence = async (masterId: number, occurrenceDate: string, eventData: object) => {
|
||||||
|
const url = `/api/events/${masterId}/occurrences/${occurrenceDate}/detach`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(eventData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || `HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|||||||
88
dashboard/src/apiHolidays.ts
Normal file
88
dashboard/src/apiHolidays.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
export type Holiday = {
|
||||||
|
id: number;
|
||||||
|
academic_period_id?: number | null;
|
||||||
|
name: string;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
region?: string | null;
|
||||||
|
source_file_name?: string | null;
|
||||||
|
imported_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listHolidays(region?: string, academicPeriodId?: number | null) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (region) {
|
||||||
|
params.set('region', region);
|
||||||
|
}
|
||||||
|
if (academicPeriodId != null) {
|
||||||
|
params.set('academicPeriodId', String(academicPeriodId));
|
||||||
|
}
|
||||||
|
const query = params.toString();
|
||||||
|
const url = query ? `/api/holidays?${query}` : '/api/holidays';
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Ferien');
|
||||||
|
return data as { holidays: Holiday[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadHolidaysCsv(file: File, academicPeriodId: number) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
form.append('academicPeriodId', String(academicPeriodId));
|
||||||
|
const res = await fetch('/api/holidays/upload', { method: 'POST', body: form });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Import der Ferien');
|
||||||
|
return data as {
|
||||||
|
success: boolean;
|
||||||
|
inserted: number;
|
||||||
|
updated: number;
|
||||||
|
merged_overlaps?: number;
|
||||||
|
skipped_duplicates?: number;
|
||||||
|
conflicts?: string[];
|
||||||
|
academic_period_id?: number | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HolidayInput = {
|
||||||
|
name: string;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
region?: string | null;
|
||||||
|
academic_period_id?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HolidayMutationResult = {
|
||||||
|
success: boolean;
|
||||||
|
holiday?: Holiday;
|
||||||
|
regenerated_events: number;
|
||||||
|
merged?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createHoliday(data: HolidayInput): Promise<HolidayMutationResult> {
|
||||||
|
const res = await fetch('/api/holidays', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok || json.error) throw new Error(json.error || 'Fehler beim Erstellen');
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateHoliday(id: number, data: Partial<HolidayInput>): Promise<HolidayMutationResult> {
|
||||||
|
const res = await fetch(`/api/holidays/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok || json.error) throw new Error(json.error || 'Fehler beim Aktualisieren');
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHoliday(id: number): Promise<{ success: boolean; regenerated_events: number }> {
|
||||||
|
const res = await fetch(`/api/holidays/${id}`, { method: 'DELETE' });
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok || json.error) throw new Error(json.error || 'Fehler beim Löschen');
|
||||||
|
return json;
|
||||||
|
}
|
||||||
168
dashboard/src/apiSystemSettings.ts
Normal file
168
dashboard/src/apiSystemSettings.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* API client for system settings
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
export interface SystemSetting {
|
||||||
|
key: string;
|
||||||
|
value: string | null;
|
||||||
|
description: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplementTableSettings {
|
||||||
|
url: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all system settings
|
||||||
|
*/
|
||||||
|
export async function getAllSettings(): Promise<{ settings: SystemSetting[] }> {
|
||||||
|
const response = await fetch(`/api/system-settings`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch settings: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific setting by key
|
||||||
|
*/
|
||||||
|
export async function getSetting(key: string): Promise<SystemSetting> {
|
||||||
|
const response = await fetch(`/api/system-settings/${key}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch setting: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update or create a setting
|
||||||
|
*/
|
||||||
|
export async function updateSetting(
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
description?: string
|
||||||
|
): Promise<SystemSetting> {
|
||||||
|
const response = await fetch(`/api/system-settings/${key}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ value, description }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to update setting: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a setting
|
||||||
|
*/
|
||||||
|
export async function deleteSetting(key: string): Promise<{ message: string }> {
|
||||||
|
const response = await fetch(`/api/system-settings/${key}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to delete setting: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get supplement table settings
|
||||||
|
*/
|
||||||
|
export async function getSupplementTableSettings(): Promise<SupplementTableSettings> {
|
||||||
|
const response = await fetch(`/api/system-settings/supplement-table`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch supplement table settings: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update supplement table settings
|
||||||
|
*/
|
||||||
|
export async function updateSupplementTableSettings(
|
||||||
|
url: string,
|
||||||
|
enabled: boolean
|
||||||
|
): Promise<SupplementTableSettings & { message: string }> {
|
||||||
|
const response = await fetch(`/api/system-settings/supplement-table`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ url, enabled }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to update supplement table settings: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get holiday banner setting
|
||||||
|
*/
|
||||||
|
export async function getHolidayBannerSetting(): Promise<{ enabled: boolean }> {
|
||||||
|
const response = await fetch(`/api/system-settings/holiday-banner`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch holiday banner setting: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update holiday banner setting
|
||||||
|
*/
|
||||||
|
export async function updateHolidayBannerSetting(
|
||||||
|
enabled: boolean
|
||||||
|
): Promise<{ enabled: boolean; message: string }> {
|
||||||
|
const response = await fetch(`/api/system-settings/holiday-banner`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to update holiday banner setting: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get organization name (public endpoint)
|
||||||
|
*/
|
||||||
|
export async function getOrganizationName(): Promise<{ name: string }> {
|
||||||
|
const response = await fetch(`/api/system-settings/organization-name`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch organization name: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update organization name (superadmin only)
|
||||||
|
*/
|
||||||
|
export async function updateOrganizationName(name: string): Promise<{ name: string; message: string }> {
|
||||||
|
const response = await fetch(`/api/system-settings/organization-name`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to update organization name: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
161
dashboard/src/apiUsers.ts
Normal file
161
dashboard/src/apiUsers.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* User management API client.
|
||||||
|
*
|
||||||
|
* Provides functions to manage users (CRUD operations).
|
||||||
|
* Access is role-based: admin can manage user/editor/admin, superadmin can manage all.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface UserData {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: 'user' | 'editor' | 'admin' | 'superadmin';
|
||||||
|
isActive: boolean;
|
||||||
|
lastLoginAt?: string;
|
||||||
|
lastPasswordChangeAt?: string;
|
||||||
|
lastFailedLoginAt?: string;
|
||||||
|
failedLoginAttempts?: number;
|
||||||
|
lockedUntil?: string;
|
||||||
|
deactivatedAt?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
role: 'user' | 'editor' | 'admin' | 'superadmin';
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserRequest {
|
||||||
|
username?: string;
|
||||||
|
role?: 'user' | 'editor' | 'admin' | 'superadmin';
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResetPasswordRequest {
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all users (filtered by current user's role).
|
||||||
|
* Admin sees: user, editor, admin
|
||||||
|
* Superadmin sees: all including superadmin
|
||||||
|
*/
|
||||||
|
export async function listUsers(): Promise<UserData[]> {
|
||||||
|
const res = await fetch('/api/users', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.error || 'Failed to fetch users');
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single user by ID.
|
||||||
|
*/
|
||||||
|
export async function getUser(userId: number): Promise<UserData> {
|
||||||
|
const res = await fetch(`/api/users/${userId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.error || 'Failed to fetch user');
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new user.
|
||||||
|
* Admin: can create user, editor, admin
|
||||||
|
* Superadmin: can create any role including superadmin
|
||||||
|
*/
|
||||||
|
export async function createUser(userData: CreateUserRequest): Promise<UserData & { message: string }> {
|
||||||
|
const res = await fetch('/api/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(userData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to create user');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a user's details.
|
||||||
|
* Restrictions:
|
||||||
|
* - Cannot change own role
|
||||||
|
* - Cannot change own active status
|
||||||
|
* - Admin cannot edit superadmin users
|
||||||
|
*/
|
||||||
|
export async function updateUser(userId: number, userData: UpdateUserRequest): Promise<UserData & { message: string }> {
|
||||||
|
const res = await fetch(`/api/users/${userId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(userData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to update user');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset a user's password.
|
||||||
|
* Admin: cannot reset superadmin passwords
|
||||||
|
* Superadmin: can reset any password
|
||||||
|
*/
|
||||||
|
export async function resetUserPassword(userId: number, password: string): Promise<{ message: string }> {
|
||||||
|
const res = await fetch(`/api/users/${userId}/password`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to reset password');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently delete a user (superadmin only).
|
||||||
|
* Cannot delete own account.
|
||||||
|
*/
|
||||||
|
export async function deleteUser(userId: number): Promise<{ message: string }> {
|
||||||
|
const res = await fetch(`/api/users/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to delete user');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
const Benutzer: React.FC = () => (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-bold mb-4">Benutzer</h2>
|
|
||||||
<p>Willkommen im Infoscreen-Management Benutzer.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
export default Benutzer;
|
|
||||||
569
dashboard/src/cldr/ca-gregorian.json
Normal file
569
dashboard/src/cldr/ca-gregorian.json
Normal file
@@ -0,0 +1,569 @@
|
|||||||
|
{
|
||||||
|
"main": {
|
||||||
|
"de": {
|
||||||
|
"identity": {
|
||||||
|
"language": "de"
|
||||||
|
},
|
||||||
|
"dates": {
|
||||||
|
"calendars": {
|
||||||
|
"gregorian": {
|
||||||
|
"months": {
|
||||||
|
"format": {
|
||||||
|
"abbreviated": {
|
||||||
|
"1": "Jan.",
|
||||||
|
"2": "Feb.",
|
||||||
|
"3": "März",
|
||||||
|
"4": "Apr.",
|
||||||
|
"5": "Mai",
|
||||||
|
"6": "Juni",
|
||||||
|
"7": "Juli",
|
||||||
|
"8": "Aug.",
|
||||||
|
"9": "Sept.",
|
||||||
|
"10": "Okt.",
|
||||||
|
"11": "Nov.",
|
||||||
|
"12": "Dez."
|
||||||
|
},
|
||||||
|
"narrow": {
|
||||||
|
"1": "J",
|
||||||
|
"2": "F",
|
||||||
|
"3": "M",
|
||||||
|
"4": "A",
|
||||||
|
"5": "M",
|
||||||
|
"6": "J",
|
||||||
|
"7": "J",
|
||||||
|
"8": "A",
|
||||||
|
"9": "S",
|
||||||
|
"10": "O",
|
||||||
|
"11": "N",
|
||||||
|
"12": "D"
|
||||||
|
},
|
||||||
|
"wide": {
|
||||||
|
"1": "Januar",
|
||||||
|
"2": "Februar",
|
||||||
|
"3": "März",
|
||||||
|
"4": "April",
|
||||||
|
"5": "Mai",
|
||||||
|
"6": "Juni",
|
||||||
|
"7": "Juli",
|
||||||
|
"8": "August",
|
||||||
|
"9": "September",
|
||||||
|
"10": "Oktober",
|
||||||
|
"11": "November",
|
||||||
|
"12": "Dezember"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"stand-alone": {
|
||||||
|
"abbreviated": {
|
||||||
|
"1": "Jan",
|
||||||
|
"2": "Feb",
|
||||||
|
"3": "Mär",
|
||||||
|
"4": "Apr",
|
||||||
|
"5": "Mai",
|
||||||
|
"6": "Jun",
|
||||||
|
"7": "Jul",
|
||||||
|
"8": "Aug",
|
||||||
|
"9": "Sep",
|
||||||
|
"10": "Okt",
|
||||||
|
"11": "Nov",
|
||||||
|
"12": "Dez"
|
||||||
|
},
|
||||||
|
"narrow": {
|
||||||
|
"1": "J",
|
||||||
|
"2": "F",
|
||||||
|
"3": "M",
|
||||||
|
"4": "A",
|
||||||
|
"5": "M",
|
||||||
|
"6": "J",
|
||||||
|
"7": "J",
|
||||||
|
"8": "A",
|
||||||
|
"9": "S",
|
||||||
|
"10": "O",
|
||||||
|
"11": "N",
|
||||||
|
"12": "D"
|
||||||
|
},
|
||||||
|
"wide": {
|
||||||
|
"1": "Januar",
|
||||||
|
"2": "Februar",
|
||||||
|
"3": "März",
|
||||||
|
"4": "April",
|
||||||
|
"5": "Mai",
|
||||||
|
"6": "Juni",
|
||||||
|
"7": "Juli",
|
||||||
|
"8": "August",
|
||||||
|
"9": "September",
|
||||||
|
"10": "Oktober",
|
||||||
|
"11": "November",
|
||||||
|
"12": "Dezember"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"days": {
|
||||||
|
"format": {
|
||||||
|
"abbreviated": {
|
||||||
|
"sun": "So.",
|
||||||
|
"mon": "Mo.",
|
||||||
|
"tue": "Di.",
|
||||||
|
"wed": "Mi.",
|
||||||
|
"thu": "Do.",
|
||||||
|
"fri": "Fr.",
|
||||||
|
"sat": "Sa."
|
||||||
|
},
|
||||||
|
"narrow": {
|
||||||
|
"sun": "S",
|
||||||
|
"mon": "M",
|
||||||
|
"tue": "D",
|
||||||
|
"wed": "M",
|
||||||
|
"thu": "D",
|
||||||
|
"fri": "F",
|
||||||
|
"sat": "S"
|
||||||
|
},
|
||||||
|
"short": {
|
||||||
|
"sun": "So.",
|
||||||
|
"mon": "Mo.",
|
||||||
|
"tue": "Di.",
|
||||||
|
"wed": "Mi.",
|
||||||
|
"thu": "Do.",
|
||||||
|
"fri": "Fr.",
|
||||||
|
"sat": "Sa."
|
||||||
|
},
|
||||||
|
"wide": {
|
||||||
|
"sun": "Sonntag",
|
||||||
|
"mon": "Montag",
|
||||||
|
"tue": "Dienstag",
|
||||||
|
"wed": "Mittwoch",
|
||||||
|
"thu": "Donnerstag",
|
||||||
|
"fri": "Freitag",
|
||||||
|
"sat": "Samstag"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"stand-alone": {
|
||||||
|
"abbreviated": {
|
||||||
|
"sun": "So",
|
||||||
|
"mon": "Mo",
|
||||||
|
"tue": "Di",
|
||||||
|
"wed": "Mi",
|
||||||
|
"thu": "Do",
|
||||||
|
"fri": "Fr",
|
||||||
|
"sat": "Sa"
|
||||||
|
},
|
||||||
|
"narrow": {
|
||||||
|
"sun": "S",
|
||||||
|
"mon": "M",
|
||||||
|
"tue": "D",
|
||||||
|
"wed": "M",
|
||||||
|
"thu": "D",
|
||||||
|
"fri": "F",
|
||||||
|
"sat": "S"
|
||||||
|
},
|
||||||
|
"short": {
|
||||||
|
"sun": "So.",
|
||||||
|
"mon": "Mo.",
|
||||||
|
"tue": "Di.",
|
||||||
|
"wed": "Mi.",
|
||||||
|
"thu": "Do.",
|
||||||
|
"fri": "Fr.",
|
||||||
|
"sat": "Sa."
|
||||||
|
},
|
||||||
|
"wide": {
|
||||||
|
"sun": "Sonntag",
|
||||||
|
"mon": "Montag",
|
||||||
|
"tue": "Dienstag",
|
||||||
|
"wed": "Mittwoch",
|
||||||
|
"thu": "Donnerstag",
|
||||||
|
"fri": "Freitag",
|
||||||
|
"sat": "Samstag"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quarters": {
|
||||||
|
"format": {
|
||||||
|
"abbreviated": {
|
||||||
|
"1": "Q1",
|
||||||
|
"2": "Q2",
|
||||||
|
"3": "Q3",
|
||||||
|
"4": "Q4"
|
||||||
|
},
|
||||||
|
"narrow": {
|
||||||
|
"1": "1",
|
||||||
|
"2": "2",
|
||||||
|
"3": "3",
|
||||||
|
"4": "4"
|
||||||
|
},
|
||||||
|
"wide": {
|
||||||
|
"1": "1. Quartal",
|
||||||
|
"2": "2. Quartal",
|
||||||
|
"3": "3. Quartal",
|
||||||
|
"4": "4. Quartal"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"stand-alone": {
|
||||||
|
"abbreviated": {
|
||||||
|
"1": "Q1",
|
||||||
|
"2": "Q2",
|
||||||
|
"3": "Q3",
|
||||||
|
"4": "Q4"
|
||||||
|
},
|
||||||
|
"narrow": {
|
||||||
|
"1": "1",
|
||||||
|
"2": "2",
|
||||||
|
"3": "3",
|
||||||
|
"4": "4"
|
||||||
|
},
|
||||||
|
"wide": {
|
||||||
|
"1": "1. Quartal",
|
||||||
|
"2": "2. Quartal",
|
||||||
|
"3": "3. Quartal",
|
||||||
|
"4": "4. Quartal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dayPeriods": {
|
||||||
|
"format": {
|
||||||
|
"abbreviated": {
|
||||||
|
"midnight": "Mitternacht",
|
||||||
|
"am": "AM",
|
||||||
|
"pm": "PM",
|
||||||
|
"morning1": "morgens",
|
||||||
|
"morning2": "vorm.",
|
||||||
|
"afternoon1": "mittags",
|
||||||
|
"afternoon2": "nachm.",
|
||||||
|
"evening1": "abends",
|
||||||
|
"night1": "nachts"
|
||||||
|
},
|
||||||
|
"narrow": {
|
||||||
|
"midnight": "Mitternacht",
|
||||||
|
"am": "AM",
|
||||||
|
"pm": "PM",
|
||||||
|
"morning1": "morgens",
|
||||||
|
"morning2": "vorm.",
|
||||||
|
"afternoon1": "mittags",
|
||||||
|
"afternoon2": "nachm.",
|
||||||
|
"evening1": "abends",
|
||||||
|
"night1": "nachts"
|
||||||
|
},
|
||||||
|
"wide": {
|
||||||
|
"midnight": "Mitternacht",
|
||||||
|
"am": "AM",
|
||||||
|
"pm": "PM",
|
||||||
|
"morning1": "morgens",
|
||||||
|
"morning2": "vormittags",
|
||||||
|
"afternoon1": "mittags",
|
||||||
|
"afternoon2": "nachmittags",
|
||||||
|
"evening1": "abends",
|
||||||
|
"night1": "nachts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"stand-alone": {
|
||||||
|
"abbreviated": {
|
||||||
|
"midnight": "Mitternacht",
|
||||||
|
"am": "AM",
|
||||||
|
"pm": "PM",
|
||||||
|
"morning1": "Morgen",
|
||||||
|
"morning2": "Vorm.",
|
||||||
|
"afternoon1": "Mittag",
|
||||||
|
"afternoon2": "Nachm.",
|
||||||
|
"evening1": "Abend",
|
||||||
|
"night1": "Nacht"
|
||||||
|
},
|
||||||
|
"narrow": {
|
||||||
|
"midnight": "Mitternacht",
|
||||||
|
"am": "AM",
|
||||||
|
"pm": "PM",
|
||||||
|
"morning1": "Morgen",
|
||||||
|
"morning2": "Vorm.",
|
||||||
|
"afternoon1": "Mittag",
|
||||||
|
"afternoon2": "Nachm.",
|
||||||
|
"evening1": "Abend",
|
||||||
|
"night1": "Nacht"
|
||||||
|
},
|
||||||
|
"wide": {
|
||||||
|
"midnight": "Mitternacht",
|
||||||
|
"am": "AM",
|
||||||
|
"pm": "PM",
|
||||||
|
"morning1": "Morgen",
|
||||||
|
"morning2": "Vormittag",
|
||||||
|
"afternoon1": "Mittag",
|
||||||
|
"afternoon2": "Nachmittag",
|
||||||
|
"evening1": "Abend",
|
||||||
|
"night1": "Nacht"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"eras": {
|
||||||
|
"eraNames": {
|
||||||
|
"0": "v. Chr.",
|
||||||
|
"0-alt-variant": "vor unserer Zeitrechnung",
|
||||||
|
"1": "n. Chr.",
|
||||||
|
"1-alt-variant": "unserer Zeitrechnung"
|
||||||
|
},
|
||||||
|
"eraAbbr": {
|
||||||
|
"0": "v. Chr.",
|
||||||
|
"0-alt-variant": "v. u. Z.",
|
||||||
|
"1": "n. Chr.",
|
||||||
|
"1-alt-variant": "u. Z."
|
||||||
|
},
|
||||||
|
"eraNarrow": {
|
||||||
|
"0": "v. Chr.",
|
||||||
|
"0-alt-variant": "v. u. Z.",
|
||||||
|
"1": "n. Chr.",
|
||||||
|
"1-alt-variant": "u. Z."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dateFormats": {
|
||||||
|
"full": "EEEE, d. MMMM y",
|
||||||
|
"long": "d. MMMM y",
|
||||||
|
"medium": "dd.MM.y",
|
||||||
|
"short": "dd.MM.yy"
|
||||||
|
},
|
||||||
|
"dateSkeletons": {
|
||||||
|
"full": "yMMMMEEEEd",
|
||||||
|
"long": "yMMMMd",
|
||||||
|
"medium": "yMMdd",
|
||||||
|
"short": "yyMMdd"
|
||||||
|
},
|
||||||
|
"timeFormats": {
|
||||||
|
"full": "HH:mm:ss zzzz",
|
||||||
|
"long": "HH:mm:ss z",
|
||||||
|
"medium": "HH:mm:ss",
|
||||||
|
"short": "HH:mm"
|
||||||
|
},
|
||||||
|
"timeSkeletons": {
|
||||||
|
"full": "HHmmsszzzz",
|
||||||
|
"long": "HHmmssz",
|
||||||
|
"medium": "HHmmss",
|
||||||
|
"short": "HHmm"
|
||||||
|
},
|
||||||
|
"dateTimeFormats": {
|
||||||
|
"full": "{1}, {0}",
|
||||||
|
"long": "{1}, {0}",
|
||||||
|
"medium": "{1}, {0}",
|
||||||
|
"short": "{1}, {0}",
|
||||||
|
"availableFormats": {
|
||||||
|
"Bh": "h B",
|
||||||
|
"Bhm": "h:mm B",
|
||||||
|
"Bhms": "h:mm:ss B",
|
||||||
|
"d": "d",
|
||||||
|
"E": "ccc",
|
||||||
|
"EBhm": "E h:mm B",
|
||||||
|
"EBhms": "E h:mm:ss B",
|
||||||
|
"Ed": "E, d.",
|
||||||
|
"Ehm": "E h:mm a",
|
||||||
|
"EHm": "E, HH:mm",
|
||||||
|
"Ehms": "E, h:mm:ss a",
|
||||||
|
"EHms": "E, HH:mm:ss",
|
||||||
|
"Gy": "y G",
|
||||||
|
"GyMd": "dd.MM.y G",
|
||||||
|
"GyMMM": "MMM y G",
|
||||||
|
"GyMMMd": "d. MMM y G",
|
||||||
|
"GyMMMEd": "E, d. MMM y G",
|
||||||
|
"h": "h 'Uhr' a",
|
||||||
|
"H": "HH 'Uhr'",
|
||||||
|
"hm": "h:mm a",
|
||||||
|
"Hm": "HH:mm",
|
||||||
|
"hms": "h:mm:ss a",
|
||||||
|
"Hms": "HH:mm:ss",
|
||||||
|
"hmsv": "h:mm:ss a v",
|
||||||
|
"Hmsv": "HH:mm:ss v",
|
||||||
|
"hmv": "h:mm a v",
|
||||||
|
"Hmv": "HH:mm v",
|
||||||
|
"M": "L",
|
||||||
|
"Md": "d.M.",
|
||||||
|
"MEd": "E, d.M.",
|
||||||
|
"MMd": "d.MM.",
|
||||||
|
"MMdd": "dd.MM.",
|
||||||
|
"MMM": "LLL",
|
||||||
|
"MMMd": "d. MMM",
|
||||||
|
"MMMEd": "E, d. MMM",
|
||||||
|
"MMMMd": "d. MMMM",
|
||||||
|
"MMMMEd": "E, d. MMMM",
|
||||||
|
"MMMMW-count-one": "'Woche' W 'im' MMMM",
|
||||||
|
"MMMMW-count-other": "'Woche' W 'im' MMMM",
|
||||||
|
"ms": "mm:ss",
|
||||||
|
"y": "y",
|
||||||
|
"yM": "M/y",
|
||||||
|
"yMd": "d.M.y",
|
||||||
|
"yMEd": "E, d.M.y",
|
||||||
|
"yMM": "MM.y",
|
||||||
|
"yMMdd": "dd.MM.y",
|
||||||
|
"yMMM": "MMM y",
|
||||||
|
"yMMMd": "d. MMM y",
|
||||||
|
"yMMMEd": "E, d. MMM y",
|
||||||
|
"yMMMM": "MMMM y",
|
||||||
|
"yQQQ": "QQQ y",
|
||||||
|
"yQQQQ": "QQQQ y",
|
||||||
|
"yw-count-one": "'Woche' w 'des' 'Jahres' Y",
|
||||||
|
"yw-count-other": "'Woche' w 'des' 'Jahres' Y"
|
||||||
|
},
|
||||||
|
"appendItems": {
|
||||||
|
"Day": "{0} ({2}: {1})",
|
||||||
|
"Day-Of-Week": "{0} {1}",
|
||||||
|
"Era": "{1} {0}",
|
||||||
|
"Hour": "{0} ({2}: {1})",
|
||||||
|
"Minute": "{0} ({2}: {1})",
|
||||||
|
"Month": "{0} ({2}: {1})",
|
||||||
|
"Quarter": "{0} ({2}: {1})",
|
||||||
|
"Second": "{0} ({2}: {1})",
|
||||||
|
"Timezone": "{0} {1}",
|
||||||
|
"Week": "{0} ({2}: {1})",
|
||||||
|
"Year": "{1} {0}"
|
||||||
|
},
|
||||||
|
"intervalFormats": {
|
||||||
|
"intervalFormatFallback": "{0} – {1}",
|
||||||
|
"Bh": {
|
||||||
|
"B": "h 'Uhr' B – h 'Uhr' B",
|
||||||
|
"h": "h–h 'Uhr' B"
|
||||||
|
},
|
||||||
|
"Bhm": {
|
||||||
|
"B": "h:mm 'Uhr' B – h:mm 'Uhr' B",
|
||||||
|
"h": "h:mm – h:mm 'Uhr' B",
|
||||||
|
"m": "h:mm – h:mm 'Uhr' B"
|
||||||
|
},
|
||||||
|
"d": {
|
||||||
|
"d": "d.–d."
|
||||||
|
},
|
||||||
|
"Gy": {
|
||||||
|
"G": "y G – y G",
|
||||||
|
"y": "y–y G"
|
||||||
|
},
|
||||||
|
"GyM": {
|
||||||
|
"G": "MM/y G – MM/y G",
|
||||||
|
"M": "MM/y – MM/y G",
|
||||||
|
"y": "MM/y – MM/y G"
|
||||||
|
},
|
||||||
|
"GyMd": {
|
||||||
|
"d": "dd.–dd.MM.y G",
|
||||||
|
"G": "dd.MM.y G – dd.MM.y G",
|
||||||
|
"M": "dd.MM. – dd.MM.y G",
|
||||||
|
"y": "dd.MM.y – dd.MM.y G"
|
||||||
|
},
|
||||||
|
"GyMEd": {
|
||||||
|
"d": "E, dd.MM.y – E, dd.MM.y G",
|
||||||
|
"G": "E, dd.MM.y G – E, dd.MM.y G",
|
||||||
|
"M": "E, dd.MM. – E, dd.MM.y G",
|
||||||
|
"y": "E, dd.MM.y – E, dd.MM.y G"
|
||||||
|
},
|
||||||
|
"GyMMM": {
|
||||||
|
"G": "MMM y G – MMM y G",
|
||||||
|
"M": "MMM–MMM y G",
|
||||||
|
"y": "MMM y – MMM y G"
|
||||||
|
},
|
||||||
|
"GyMMMd": {
|
||||||
|
"d": "d.–d. MMM y G",
|
||||||
|
"G": "d. MMM y G – d. MMM y G",
|
||||||
|
"M": "d. MMM – d. MMM y G",
|
||||||
|
"y": "d. MMM y – d. MMM y G"
|
||||||
|
},
|
||||||
|
"GyMMMEd": {
|
||||||
|
"d": "E, d. – E, d. MMM y G",
|
||||||
|
"G": "E, d. MMM y G – E E, d. MMM y G",
|
||||||
|
"M": "E, d. MMM – E, d. MMM y G",
|
||||||
|
"y": "E, d. MMM y – E, d. MMM y G"
|
||||||
|
},
|
||||||
|
"h": {
|
||||||
|
"a": "h 'Uhr' a – h 'Uhr' a",
|
||||||
|
"h": "h – h 'Uhr' a"
|
||||||
|
},
|
||||||
|
"H": {
|
||||||
|
"H": "HH–HH 'Uhr'"
|
||||||
|
},
|
||||||
|
"hm": {
|
||||||
|
"a": "h:mm a – h:mm a",
|
||||||
|
"h": "h:mm–h:mm a",
|
||||||
|
"m": "h:mm–h:mm a"
|
||||||
|
},
|
||||||
|
"Hm": {
|
||||||
|
"H": "HH:mm–HH:mm 'Uhr'",
|
||||||
|
"m": "HH:mm–HH:mm 'Uhr'"
|
||||||
|
},
|
||||||
|
"hmv": {
|
||||||
|
"a": "h:mm a – h:mm a v",
|
||||||
|
"h": "h:mm–h:mm a v",
|
||||||
|
"m": "h:mm–h:mm a v"
|
||||||
|
},
|
||||||
|
"Hmv": {
|
||||||
|
"H": "HH:mm–HH:mm 'Uhr' v",
|
||||||
|
"m": "HH:mm–HH:mm 'Uhr' v"
|
||||||
|
},
|
||||||
|
"hv": {
|
||||||
|
"a": "h a – h a v",
|
||||||
|
"h": "h–h a v"
|
||||||
|
},
|
||||||
|
"Hv": {
|
||||||
|
"H": "HH–HH 'Uhr' v"
|
||||||
|
},
|
||||||
|
"M": {
|
||||||
|
"M": "MM–MM"
|
||||||
|
},
|
||||||
|
"Md": {
|
||||||
|
"d": "dd.–dd.MM.",
|
||||||
|
"M": "dd.MM. – dd.MM."
|
||||||
|
},
|
||||||
|
"MEd": {
|
||||||
|
"d": "E, dd. – E, dd.MM.",
|
||||||
|
"M": "E, dd.MM. – E, dd.MM."
|
||||||
|
},
|
||||||
|
"MMM": {
|
||||||
|
"M": "MMM–MMM"
|
||||||
|
},
|
||||||
|
"MMMd": {
|
||||||
|
"d": "d.–d. MMM",
|
||||||
|
"M": "d. MMM – d. MMM"
|
||||||
|
},
|
||||||
|
"MMMEd": {
|
||||||
|
"d": "E, d. – E, d. MMM",
|
||||||
|
"M": "E, d. MMM – E, d. MMM"
|
||||||
|
},
|
||||||
|
"MMMM": {
|
||||||
|
"M": "LLLL–LLLL"
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"y": "y–y"
|
||||||
|
},
|
||||||
|
"yM": {
|
||||||
|
"M": "M/y – M/y",
|
||||||
|
"y": "M/y – M/y"
|
||||||
|
},
|
||||||
|
"yMd": {
|
||||||
|
"d": "dd.–dd.MM.y",
|
||||||
|
"M": "dd.MM. – dd.MM.y",
|
||||||
|
"y": "dd.MM.y – dd.MM.y"
|
||||||
|
},
|
||||||
|
"yMEd": {
|
||||||
|
"d": "E, dd. – E, dd.MM.y",
|
||||||
|
"M": "E, dd.MM. – E, dd.MM.y",
|
||||||
|
"y": "E, dd.MM.y – E, dd.MM.y"
|
||||||
|
},
|
||||||
|
"yMMM": {
|
||||||
|
"M": "MMM–MMM y",
|
||||||
|
"y": "MMM y – MMM y"
|
||||||
|
},
|
||||||
|
"yMMMd": {
|
||||||
|
"d": "d.–d. MMM y",
|
||||||
|
"M": "d. MMM – d. MMM y",
|
||||||
|
"y": "d. MMM y – d. MMM y"
|
||||||
|
},
|
||||||
|
"yMMMEd": {
|
||||||
|
"d": "E, d. – E, d. MMM y",
|
||||||
|
"M": "E, d. MMM – E, d. MMM y",
|
||||||
|
"y": "E, d. MMM y – E, d. MMM y"
|
||||||
|
},
|
||||||
|
"yMMMM": {
|
||||||
|
"M": "MMMM–MMMM y",
|
||||||
|
"y": "MMMM y – MMMM y"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dateTimeFormats-atTime": {
|
||||||
|
"standard": {
|
||||||
|
"full": "{1} 'um' {0}",
|
||||||
|
"long": "{1} 'um' {0}",
|
||||||
|
"medium": "{1}, {0}",
|
||||||
|
"short": "{1}, {0}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
394
dashboard/src/cldr/numberingSystems.json
Normal file
394
dashboard/src/cldr/numberingSystems.json
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
{
|
||||||
|
"supplemental": {
|
||||||
|
"version": {
|
||||||
|
"_unicodeVersion": "16.0.0",
|
||||||
|
"_cldrVersion": "47"
|
||||||
|
},
|
||||||
|
"numberingSystems": {
|
||||||
|
"adlm": {
|
||||||
|
"_digits": "𞥐𞥑𞥒𞥓𞥔𞥕𞥖𞥗𞥘𞥙",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"ahom": {
|
||||||
|
"_digits": "𑜰𑜱𑜲𑜳𑜴𑜵𑜶𑜷𑜸𑜹",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"arab": {
|
||||||
|
"_digits": "٠١٢٣٤٥٦٧٨٩",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"arabext": {
|
||||||
|
"_digits": "۰۱۲۳۴۵۶۷۸۹",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"armn": {
|
||||||
|
"_rules": "armenian-upper",
|
||||||
|
"_type": "algorithmic"
|
||||||
|
},
|
||||||
|
"armnlow": {
|
||||||
|
"_rules": "armenian-lower",
|
||||||
|
"_type": "algorithmic"
|
||||||
|
},
|
||||||
|
"bali": {
|
||||||
|
"_digits": "᭐᭑᭒᭓᭔᭕᭖᭗᭘᭙",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"beng": {
|
||||||
|
"_digits": "০১২৩৪৫৬৭৮৯",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"bhks": {
|
||||||
|
"_digits": "𑱐𑱑𑱒𑱓𑱔𑱕𑱖𑱗𑱘𑱙",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"brah": {
|
||||||
|
"_digits": "𑁦𑁧𑁨𑁩𑁪𑁫𑁬𑁭𑁮𑁯",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"cakm": {
|
||||||
|
"_digits": "𑄶𑄷𑄸𑄹𑄺𑄻𑄼𑄽𑄾𑄿",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"cham": {
|
||||||
|
"_digits": "꩐꩑꩒꩓꩔꩕꩖꩗꩘꩙",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"cyrl": {
|
||||||
|
"_rules": "cyrillic-lower",
|
||||||
|
"_type": "algorithmic"
|
||||||
|
},
|
||||||
|
"deva": {
|
||||||
|
"_digits": "०१२३४५६७८९",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"diak": {
|
||||||
|
"_digits": "𑥐𑥑𑥒𑥓𑥔𑥕𑥖𑥗𑥘𑥙",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"ethi": {
|
||||||
|
"_rules": "ethiopic",
|
||||||
|
"_type": "algorithmic"
|
||||||
|
},
|
||||||
|
"fullwide": {
|
||||||
|
"_digits": "0123456789",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"gara": {
|
||||||
|
"_digits": "",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"geor": {
|
||||||
|
"_rules": "georgian",
|
||||||
|
"_type": "algorithmic"
|
||||||
|
},
|
||||||
|
"gong": {
|
||||||
|
"_digits": "𑶠𑶡𑶢𑶣𑶤𑶥𑶦𑶧𑶨𑶩",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"gonm": {
|
||||||
|
"_digits": "𑵐𑵑𑵒𑵓𑵔𑵕𑵖𑵗𑵘𑵙",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"grek": {
|
||||||
|
"_rules": "greek-upper",
|
||||||
|
"_type": "algorithmic"
|
||||||
|
},
|
||||||
|
"greklow": {
|
||||||
|
"_rules": "greek-lower",
|
||||||
|
"_type": "algorithmic"
|
||||||
|
},
|
||||||
|
"gujr": {
|
||||||
|
"_digits": "૦૧૨૩૪૫૬૭૮૯",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"gukh": {
|
||||||
|
"_digits": "",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"guru": {
|
||||||
|
"_digits": "੦੧੨੩੪੫੬੭੮੯",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"hanidays": {
|
||||||
|
"_rules": "zh/SpelloutRules/spellout-numbering-days",
|
||||||
|
"_type": "algorithmic"
|
||||||
|
},
|
||||||
|
"hanidec": {
|
||||||
|
"_digits": "〇一二三四五六七八九",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"hans": {
|
||||||
|
"_rules": "zh/SpelloutRules/spellout-cardinal",
|
||||||
|
"_type": "algorithmic"
|
||||||
|
},
|
||||||
|
"hansfin": {
|
||||||
|
"_rules": "zh/SpelloutRules/spellout-cardinal-financial",
|
||||||
|
"_type": "algorithmic"
|
||||||
|
},
|
||||||
|
"hant": {
|
||||||
|
"_rules": "zh_Hant/SpelloutRules/spellout-cardinal",
|
||||||
|
"_type": "algorithmic"
|
||||||
|
},
|
||||||
|
"hantfin": {
|
||||||
|
"_rules": "zh_Hant/SpelloutRules/spellout-cardinal-financial",
|
||||||
|
"_type": "algorithmic"
|
||||||
|
},
|
||||||
|
"hebr": {
|
||||||
|
"_rules": "hebrew",
|
||||||
|
"_type": "algorithmic"
|
||||||
|
},
|
||||||
|
"hmng": {
|
||||||
|
"_digits": "𖭐𖭑𖭒𖭓𖭔𖭕𖭖𖭗𖭘𖭙",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"hmnp": {
|
||||||
|
"_digits": "𞅀𞅁𞅂𞅃𞅄𞅅𞅆𞅇𞅈𞅉",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"java": {
|
||||||
|
"_digits": "꧐꧑꧒꧓꧔꧕꧖꧗꧘꧙",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"jpan": {
|
||||||
|
"_rules": "ja/SpelloutRules/spellout-cardinal",
|
||||||
|
"_type": "algorithmic"
|
||||||
|
},
|
||||||
|
"jpanfin": {
|
||||||
|
"_rules": "ja/SpelloutRules/spellout-cardinal-financial",
|
||||||
|
"_type": "algorithmic"
|
||||||
|
},
|
||||||
|
"jpanyear": {
|
||||||
|
"_rules": "ja/SpelloutRules/spellout-numbering-year-latn",
|
||||||
|
"_type": "algorithmic"
|
||||||
|
},
|
||||||
|
"kali": {
|
||||||
|
"_digits": "꤀꤁꤂꤃꤄꤅꤆꤇꤈꤉",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"kawi": {
|
||||||
|
"_digits": "𑽐𑽑𑽒𑽓𑽔𑽕𑽖𑽗𑽘𑽙",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"khmr": {
|
||||||
|
"_digits": "០១២៣៤៥៦៧៨៩",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"knda": {
|
||||||
|
"_digits": "೦೧೨೩೪೫೬೭೮೯",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"krai": {
|
||||||
|
"_digits": "",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"lana": {
|
||||||
|
"_digits": "᪀᪁᪂᪃᪄᪅᪆᪇᪈᪉",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"lanatham": {
|
||||||
|
"_digits": "᪐᪑᪒᪓᪔᪕᪖᪗᪘᪙",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"laoo": {
|
||||||
|
"_digits": "໐໑໒໓໔໕໖໗໘໙",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"latn": {
|
||||||
|
"_digits": "0123456789",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"lepc": {
|
||||||
|
"_digits": "᱀᱁᱂᱃᱄᱅᱆᱇᱈᱉",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"limb": {
|
||||||
|
"_digits": "᥆᥇᥈᥉᥊᥋᥌᥍᥎᥏",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"mathbold": {
|
||||||
|
"_digits": "𝟎𝟏𝟐𝟑𝟒𝟓𝟔𝟕𝟖𝟗",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"mathdbl": {
|
||||||
|
"_digits": "𝟘𝟙𝟚𝟛𝟜𝟝𝟞𝟟𝟠𝟡",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"mathmono": {
|
||||||
|
"_digits": "𝟶𝟷𝟸𝟹𝟺𝟻𝟼𝟽𝟾𝟿",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"mathsanb": {
|
||||||
|
"_digits": "𝟬𝟭𝟮𝟯𝟰𝟱𝟲𝟳𝟴𝟵",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"mathsans": {
|
||||||
|
"_digits": "𝟢𝟣𝟤𝟥𝟦𝟧𝟨𝟩𝟪𝟫",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"mlym": {
|
||||||
|
"_digits": "൦൧൨൩൪൫൬൭൮൯",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"modi": {
|
||||||
|
"_digits": "𑙐𑙑𑙒𑙓𑙔𑙕𑙖𑙗𑙘𑙙",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"mong": {
|
||||||
|
"_digits": "᠐᠑᠒᠓᠔᠕᠖᠗᠘᠙",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"mroo": {
|
||||||
|
"_digits": "𖩠𖩡𖩢𖩣𖩤𖩥𖩦𖩧𖩨𖩩",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"mtei": {
|
||||||
|
"_digits": "꯰꯱꯲꯳꯴꯵꯶꯷꯸꯹",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"mymr": {
|
||||||
|
"_digits": "၀၁၂၃၄၅၆၇၈၉",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"mymrepka": {
|
||||||
|
"_digits": "",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"mymrpao": {
|
||||||
|
"_digits": "",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"mymrshan": {
|
||||||
|
"_digits": "႐႑႒႓႔႕႖႗႘႙",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"mymrtlng": {
|
||||||
|
"_digits": "꧰꧱꧲꧳꧴꧵꧶꧷꧸꧹",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"nagm": {
|
||||||
|
"_digits": "𞓰𞓱𞓲𞓳𞓴𞓵𞓶𞓷𞓸𞓹",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"newa": {
|
||||||
|
"_digits": "𑑐𑑑𑑒𑑓𑑔𑑕𑑖𑑗𑑘𑑙",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"nkoo": {
|
||||||
|
"_digits": "߀߁߂߃߄߅߆߇߈߉",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"olck": {
|
||||||
|
"_digits": "᱐᱑᱒᱓᱔᱕᱖᱗᱘᱙",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"onao": {
|
||||||
|
"_digits": "",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"orya": {
|
||||||
|
"_digits": "୦୧୨୩୪୫୬୭୮୯",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"osma": {
|
||||||
|
"_digits": "𐒠𐒡𐒢𐒣𐒤𐒥𐒦𐒧𐒨𐒩",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"outlined": {
|
||||||
|
"_digits": "",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"rohg": {
|
||||||
|
"_digits": "𐴰𐴱𐴲𐴳𐴴𐴵𐴶𐴷𐴸𐴹",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"roman": {
|
||||||
|
"_rules": "roman-upper",
|
||||||
|
"_type": "algorithmic"
|
||||||
|
},
|
||||||
|
"romanlow": {
|
||||||
|
"_rules": "roman-lower",
|
||||||
|
"_type": "algorithmic"
|
||||||
|
},
|
||||||
|
"saur": {
|
||||||
|
"_digits": "꣐꣑꣒꣓꣔꣕꣖꣗꣘꣙",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"segment": {
|
||||||
|
"_digits": "🯰🯱🯲🯳🯴🯵🯶🯷🯸🯹",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"shrd": {
|
||||||
|
"_digits": "𑇐𑇑𑇒𑇓𑇔𑇕𑇖𑇗𑇘𑇙",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"sind": {
|
||||||
|
"_digits": "𑋰𑋱𑋲𑋳𑋴𑋵𑋶𑋷𑋸𑋹",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"sinh": {
|
||||||
|
"_digits": "෦෧෨෩෪෫෬෭෮෯",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"sora": {
|
||||||
|
"_digits": "𑃰𑃱𑃲𑃳𑃴𑃵𑃶𑃷𑃸𑃹",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"sund": {
|
||||||
|
"_digits": "᮰᮱᮲᮳᮴᮵᮶᮷᮸᮹",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"sunu": {
|
||||||
|
"_digits": "",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"takr": {
|
||||||
|
"_digits": "𑛀𑛁𑛂𑛃𑛄𑛅𑛆𑛇𑛈𑛉",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"talu": {
|
||||||
|
"_digits": "᧐᧑᧒᧓᧔᧕᧖᧗᧘᧙",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"taml": {
|
||||||
|
"_rules": "tamil",
|
||||||
|
"_type": "algorithmic"
|
||||||
|
},
|
||||||
|
"tamldec": {
|
||||||
|
"_digits": "௦௧௨௩௪௫௬௭௮௯",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"telu": {
|
||||||
|
"_digits": "౦౧౨౩౪౫౬౭౮౯",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"thai": {
|
||||||
|
"_digits": "๐๑๒๓๔๕๖๗๘๙",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"tibt": {
|
||||||
|
"_digits": "༠༡༢༣༤༥༦༧༨༩",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"tirh": {
|
||||||
|
"_digits": "𑓐𑓑𑓒𑓓𑓔𑓕𑓖𑓗𑓘𑓙",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"tnsa": {
|
||||||
|
"_digits": "𖫀𖫁𖫂𖫃𖫄𖫅𖫆𖫇𖫈𖫉",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"vaii": {
|
||||||
|
"_digits": "꘠꘡꘢꘣꘤꘥꘦꘧꘨꘩",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"wara": {
|
||||||
|
"_digits": "𑣠𑣡𑣢𑣣𑣤𑣥𑣦𑣧𑣨𑣩",
|
||||||
|
"_type": "numeric"
|
||||||
|
},
|
||||||
|
"wcho": {
|
||||||
|
"_digits": "𞋰𞋱𞋲𞋳𞋴𞋵𞋶𞋷𞋸𞋹",
|
||||||
|
"_type": "numeric"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
164
dashboard/src/cldr/numbers.json
Normal file
164
dashboard/src/cldr/numbers.json
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
{
|
||||||
|
"main": {
|
||||||
|
"de": {
|
||||||
|
"identity": {
|
||||||
|
"language": "de"
|
||||||
|
},
|
||||||
|
"numbers": {
|
||||||
|
"defaultNumberingSystem": "latn",
|
||||||
|
"otherNumberingSystems": {
|
||||||
|
"native": "latn"
|
||||||
|
},
|
||||||
|
"minimumGroupingDigits": "1",
|
||||||
|
"symbols-numberSystem-latn": {
|
||||||
|
"decimal": ",",
|
||||||
|
"group": ".",
|
||||||
|
"list": ";",
|
||||||
|
"percentSign": "%",
|
||||||
|
"plusSign": "+",
|
||||||
|
"minusSign": "-",
|
||||||
|
"approximatelySign": "≈",
|
||||||
|
"exponential": "E",
|
||||||
|
"superscriptingExponent": "·",
|
||||||
|
"perMille": "‰",
|
||||||
|
"infinity": "∞",
|
||||||
|
"nan": "NaN",
|
||||||
|
"timeSeparator": ":"
|
||||||
|
},
|
||||||
|
"decimalFormats-numberSystem-latn": {
|
||||||
|
"standard": "#,##0.###",
|
||||||
|
"long": {
|
||||||
|
"decimalFormat": {
|
||||||
|
"1000-count-one": "0 Tausend",
|
||||||
|
"1000-count-other": "0 Tausend",
|
||||||
|
"10000-count-one": "00 Tausend",
|
||||||
|
"10000-count-other": "00 Tausend",
|
||||||
|
"100000-count-one": "000 Tausend",
|
||||||
|
"100000-count-other": "000 Tausend",
|
||||||
|
"1000000-count-one": "0 Million",
|
||||||
|
"1000000-count-other": "0 Millionen",
|
||||||
|
"10000000-count-one": "00 Millionen",
|
||||||
|
"10000000-count-other": "00 Millionen",
|
||||||
|
"100000000-count-one": "000 Millionen",
|
||||||
|
"100000000-count-other": "000 Millionen",
|
||||||
|
"1000000000-count-one": "0 Milliarde",
|
||||||
|
"1000000000-count-other": "0 Milliarden",
|
||||||
|
"10000000000-count-one": "00 Milliarden",
|
||||||
|
"10000000000-count-other": "00 Milliarden",
|
||||||
|
"100000000000-count-one": "000 Milliarden",
|
||||||
|
"100000000000-count-other": "000 Milliarden",
|
||||||
|
"1000000000000-count-one": "0 Billion",
|
||||||
|
"1000000000000-count-other": "0 Billionen",
|
||||||
|
"10000000000000-count-one": "00 Billionen",
|
||||||
|
"10000000000000-count-other": "00 Billionen",
|
||||||
|
"100000000000000-count-one": "000 Billionen",
|
||||||
|
"100000000000000-count-other": "000 Billionen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"short": {
|
||||||
|
"decimalFormat": {
|
||||||
|
"1000-count-one": "0",
|
||||||
|
"1000-count-other": "0",
|
||||||
|
"10000-count-one": "0",
|
||||||
|
"10000-count-other": "0",
|
||||||
|
"100000-count-one": "0",
|
||||||
|
"100000-count-other": "0",
|
||||||
|
"1000000-count-one": "0 Mio'.'",
|
||||||
|
"1000000-count-other": "0 Mio'.'",
|
||||||
|
"10000000-count-one": "00 Mio'.'",
|
||||||
|
"10000000-count-other": "00 Mio'.'",
|
||||||
|
"100000000-count-one": "000 Mio'.'",
|
||||||
|
"100000000-count-other": "000 Mio'.'",
|
||||||
|
"1000000000-count-one": "0 Mrd'.'",
|
||||||
|
"1000000000-count-other": "0 Mrd'.'",
|
||||||
|
"10000000000-count-one": "00 Mrd'.'",
|
||||||
|
"10000000000-count-other": "00 Mrd'.'",
|
||||||
|
"100000000000-count-one": "000 Mrd'.'",
|
||||||
|
"100000000000-count-other": "000 Mrd'.'",
|
||||||
|
"1000000000000-count-one": "0 Bio'.'",
|
||||||
|
"1000000000000-count-other": "0 Bio'.'",
|
||||||
|
"10000000000000-count-one": "00 Bio'.'",
|
||||||
|
"10000000000000-count-other": "00 Bio'.'",
|
||||||
|
"100000000000000-count-one": "000 Bio'.'",
|
||||||
|
"100000000000000-count-other": "000 Bio'.'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scientificFormats-numberSystem-latn": {
|
||||||
|
"standard": "#E0"
|
||||||
|
},
|
||||||
|
"percentFormats-numberSystem-latn": {
|
||||||
|
"standard": "#,##0 %"
|
||||||
|
},
|
||||||
|
"currencyFormats-numberSystem-latn": {
|
||||||
|
"currencySpacing": {
|
||||||
|
"beforeCurrency": {
|
||||||
|
"currencyMatch": "[[:^S:]&[:^Z:]]",
|
||||||
|
"surroundingMatch": "[:digit:]",
|
||||||
|
"insertBetween": " "
|
||||||
|
},
|
||||||
|
"afterCurrency": {
|
||||||
|
"currencyMatch": "[[:^S:]&[:^Z:]]",
|
||||||
|
"surroundingMatch": "[:digit:]",
|
||||||
|
"insertBetween": " "
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"standard": "#,##0.00 ¤",
|
||||||
|
"standard-alphaNextToNumber": "¤ #,##0.00",
|
||||||
|
"standard-noCurrency": "#,##0.00",
|
||||||
|
"accounting": "#,##0.00 ¤",
|
||||||
|
"accounting-alphaNextToNumber": "¤ #,##0.00",
|
||||||
|
"accounting-noCurrency": "#,##0.00",
|
||||||
|
"short": {
|
||||||
|
"standard": {
|
||||||
|
"1000-count-one": "0",
|
||||||
|
"1000-count-other": "0",
|
||||||
|
"10000-count-one": "0",
|
||||||
|
"10000-count-other": "0",
|
||||||
|
"100000-count-one": "0",
|
||||||
|
"100000-count-other": "0",
|
||||||
|
"1000000-count-one": "0 Mio'.' ¤",
|
||||||
|
"1000000-count-other": "0 Mio'.' ¤",
|
||||||
|
"10000000-count-one": "00 Mio'.' ¤",
|
||||||
|
"10000000-count-other": "00 Mio'.' ¤",
|
||||||
|
"100000000-count-one": "000 Mio'.' ¤",
|
||||||
|
"100000000-count-other": "000 Mio'.' ¤",
|
||||||
|
"1000000000-count-one": "0 Mrd'.' ¤",
|
||||||
|
"1000000000-count-other": "0 Mrd'.' ¤",
|
||||||
|
"10000000000-count-one": "00 Mrd'.' ¤",
|
||||||
|
"10000000000-count-other": "00 Mrd'.' ¤",
|
||||||
|
"100000000000-count-one": "000 Mrd'.' ¤",
|
||||||
|
"100000000000-count-other": "000 Mrd'.' ¤",
|
||||||
|
"1000000000000-count-one": "0 Bio'.' ¤",
|
||||||
|
"1000000000000-count-other": "0 Bio'.' ¤",
|
||||||
|
"10000000000000-count-one": "00 Bio'.' ¤",
|
||||||
|
"10000000000000-count-other": "00 Bio'.' ¤",
|
||||||
|
"100000000000000-count-one": "000 Bio'.' ¤",
|
||||||
|
"100000000000000-count-other": "000 Bio'.' ¤"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"currencyPatternAppendISO": "{0} ¤¤",
|
||||||
|
"unitPattern-count-other": "{0} {1}"
|
||||||
|
},
|
||||||
|
"miscPatterns-numberSystem-latn": {
|
||||||
|
"approximately": "≈{0}",
|
||||||
|
"atLeast": "{0}+",
|
||||||
|
"atMost": "≤{0}",
|
||||||
|
"range": "{0}–{1}"
|
||||||
|
},
|
||||||
|
"minimalPairs": {
|
||||||
|
"pluralMinimalPairs-count-one": "{0} Tag",
|
||||||
|
"pluralMinimalPairs-count-other": "{0} Tage",
|
||||||
|
"other": "{0}. Abzweigung nach rechts nehmen",
|
||||||
|
"accusative": "… für {0} …",
|
||||||
|
"dative": "… mit {0} …",
|
||||||
|
"genitive": "Anstatt {0} …",
|
||||||
|
"nominative": "{0} kostet (kosten) € 3,50.",
|
||||||
|
"feminine": "Die {0} ist …",
|
||||||
|
"masculine": "Der {0} ist …",
|
||||||
|
"neuter": "Das {0} ist …"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1563
dashboard/src/cldr/timeZoneNames.json
Normal file
1563
dashboard/src/cldr/timeZoneNames.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,266 @@
|
|||||||
import React from 'react';
|
import SetupModeButton from './components/SetupModeButton';
|
||||||
const Infoscreens: React.FC = () => (
|
import React, { useEffect, useState } from 'react';
|
||||||
<div>
|
import { useClientDelete } from './hooks/useClientDelete';
|
||||||
<h2 className="text-xl font-bold mb-4">Infoscreens</h2>
|
import { fetchClients, updateClient } from './apiClients';
|
||||||
<p>Willkommen im Infoscreen-Management Infoscreens.</p>
|
import type { Client } from './apiClients';
|
||||||
|
import {
|
||||||
|
GridComponent,
|
||||||
|
ColumnsDirective,
|
||||||
|
ColumnDirective,
|
||||||
|
Page,
|
||||||
|
Inject,
|
||||||
|
Toolbar,
|
||||||
|
Search,
|
||||||
|
Sort,
|
||||||
|
Edit,
|
||||||
|
} from '@syncfusion/ej2-react-grids';
|
||||||
|
import { DialogComponent } from '@syncfusion/ej2-react-popups';
|
||||||
|
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
|
||||||
|
|
||||||
|
// Raumgruppen werden dynamisch aus der API geladen
|
||||||
|
|
||||||
|
// Details dialog renders via Syncfusion Dialog for consistent look & feel
|
||||||
|
function DetailsContent({ client, groupIdToName }: { client: Client; groupIdToName: Record<string | number, string> }) {
|
||||||
|
return (
|
||||||
|
<div className="e-card-content">
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(client)
|
||||||
|
.filter(
|
||||||
|
([key]) =>
|
||||||
|
![
|
||||||
|
'index',
|
||||||
|
'is_active',
|
||||||
|
'type',
|
||||||
|
'column',
|
||||||
|
'group_name',
|
||||||
|
'foreignKeyData',
|
||||||
|
'hardware_token',
|
||||||
|
].includes(key)
|
||||||
|
)
|
||||||
|
.map(([key, value]) => (
|
||||||
|
<tr key={key}>
|
||||||
|
<td style={{ fontWeight: 600, padding: '6px 8px', width: '40%' }}>
|
||||||
|
{key === 'group_id'
|
||||||
|
? 'Raumgruppe'
|
||||||
|
: key === 'ip'
|
||||||
|
? 'IP-Adresse'
|
||||||
|
: key === 'registration_time'
|
||||||
|
? 'Registriert am'
|
||||||
|
: key === 'description'
|
||||||
|
? 'Beschreibung'
|
||||||
|
: key === 'last_alive'
|
||||||
|
? 'Letzter Kontakt'
|
||||||
|
: key === 'model'
|
||||||
|
? 'Modell'
|
||||||
|
: key === 'uuid'
|
||||||
|
? 'Client-Code'
|
||||||
|
: key === 'os_version'
|
||||||
|
? 'Betriebssystem'
|
||||||
|
: key === 'software_version'
|
||||||
|
? 'Clientsoftware'
|
||||||
|
: key === 'macs'
|
||||||
|
? 'MAC-Adressen'
|
||||||
|
: key.charAt(0).toUpperCase() + key.slice(1)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 8px' }}>
|
||||||
|
{key === 'group_id'
|
||||||
|
? value !== undefined
|
||||||
|
? groupIdToName[value as string | number] || String(value)
|
||||||
|
: ''
|
||||||
|
: key === 'registration_time' && value
|
||||||
|
? new Date(
|
||||||
|
(value as string).endsWith('Z') ? (value as string) : String(value) + 'Z'
|
||||||
|
).toLocaleString()
|
||||||
|
: key === 'last_alive' && value
|
||||||
|
? String(value)
|
||||||
|
: key === 'macs' && typeof value === 'string'
|
||||||
|
? value.replace(/,\s*/g, ', ')
|
||||||
|
: String(value)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
export default Infoscreens;
|
}
|
||||||
|
|
||||||
|
const Clients: React.FC = () => {
|
||||||
|
const [clients, setClients] = useState<Client[]>([]);
|
||||||
|
const [groups, setGroups] = useState<{ id: number; name: string }[]>([]);
|
||||||
|
const [detailsClient, setDetailsClient] = useState<Client | null>(null);
|
||||||
|
|
||||||
|
const { showDialog, deleteClientId, handleDelete, confirmDelete, cancelDelete } = useClientDelete(
|
||||||
|
uuid => setClients(prev => prev.filter(c => c.uuid !== uuid))
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchClients().then(setClients);
|
||||||
|
// Gruppen auslesen
|
||||||
|
import('./apiGroups').then(mod => mod.fetchGroups()).then(setGroups);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Map group_id zu group_name
|
||||||
|
const groupIdToName: Record<string | number, string> = {};
|
||||||
|
groups.forEach(g => {
|
||||||
|
groupIdToName[g.id] = g.name;
|
||||||
|
});
|
||||||
|
|
||||||
|
// DataGrid data mit korrektem Gruppennamen und formatierten Zeitangaben
|
||||||
|
const gridData = clients.map(c => ({
|
||||||
|
...c,
|
||||||
|
group_name: c.group_id !== undefined ? groupIdToName[c.group_id] || String(c.group_id) : '',
|
||||||
|
last_alive: c.last_alive
|
||||||
|
? new Date(
|
||||||
|
(c.last_alive as string).endsWith('Z') ? (c.last_alive as string) : c.last_alive + 'Z'
|
||||||
|
).toLocaleString()
|
||||||
|
: '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// DataGrid row template für Details- und Entfernen-Button
|
||||||
|
const detailsButtonTemplate = (props: Client) => (
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||||
|
<ButtonComponent cssClass="e-primary" onClick={() => setDetailsClient(props)}>
|
||||||
|
Details
|
||||||
|
</ButtonComponent>
|
||||||
|
<ButtonComponent
|
||||||
|
cssClass="e-danger"
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(props.uuid);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</ButtonComponent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="dialog-target">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 16,
|
||||||
|
gap: 12,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 700 }}>
|
||||||
|
Client-Übersicht
|
||||||
|
</h2>
|
||||||
|
<SetupModeButton />
|
||||||
|
</div>
|
||||||
|
{groups.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<GridComponent
|
||||||
|
dataSource={gridData}
|
||||||
|
allowPaging={true}
|
||||||
|
pageSettings={{ pageSize: 10 }}
|
||||||
|
toolbar={['Search', 'Edit', 'Update', 'Cancel']}
|
||||||
|
allowSorting={true}
|
||||||
|
allowFiltering={true}
|
||||||
|
height={420}
|
||||||
|
editSettings={{
|
||||||
|
allowEditing: true,
|
||||||
|
allowAdding: false,
|
||||||
|
allowDeleting: false,
|
||||||
|
mode: 'Normal',
|
||||||
|
}}
|
||||||
|
actionComplete={async (args: {
|
||||||
|
requestType: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}) => {
|
||||||
|
if (args.requestType === 'save') {
|
||||||
|
const { uuid, description, model } = args.data as {
|
||||||
|
uuid: string;
|
||||||
|
description: string;
|
||||||
|
model: string;
|
||||||
|
};
|
||||||
|
// API-Aufruf zum Speichern
|
||||||
|
await updateClient(uuid, { description, model });
|
||||||
|
// Nach dem Speichern neu laden
|
||||||
|
fetchClients().then(setClients);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ColumnsDirective>
|
||||||
|
<ColumnDirective
|
||||||
|
field="description"
|
||||||
|
headerText="Beschreibung"
|
||||||
|
allowEditing={true}
|
||||||
|
width="180"
|
||||||
|
/>
|
||||||
|
<ColumnDirective
|
||||||
|
field="group_name"
|
||||||
|
headerText="Raumgruppe"
|
||||||
|
allowEditing={false}
|
||||||
|
width="140"
|
||||||
|
/>
|
||||||
|
<ColumnDirective field="uuid" headerText="UUID" allowEditing={false} width="160" />
|
||||||
|
<ColumnDirective field="ip" headerText="IP-Adresse" allowEditing={false} width="100" />
|
||||||
|
<ColumnDirective
|
||||||
|
field="last_alive"
|
||||||
|
headerText="Last Alive"
|
||||||
|
allowEditing={false}
|
||||||
|
width="150"
|
||||||
|
/>
|
||||||
|
<ColumnDirective field="model" headerText="Model" allowEditing={true} width="140" />
|
||||||
|
<ColumnDirective
|
||||||
|
headerText="Aktion"
|
||||||
|
width="210"
|
||||||
|
template={detailsButtonTemplate}
|
||||||
|
textAlign="Center"
|
||||||
|
allowEditing={false}
|
||||||
|
/>
|
||||||
|
</ColumnsDirective>
|
||||||
|
<Inject services={[Page, Toolbar, Search, Sort, Edit]} />
|
||||||
|
</GridComponent>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: '#6b7280' }}>Raumgruppen werden geladen ...</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Details-Dialog */}
|
||||||
|
{detailsClient && (
|
||||||
|
<DialogComponent
|
||||||
|
visible={!!detailsClient}
|
||||||
|
header="Client-Details"
|
||||||
|
showCloseIcon={true}
|
||||||
|
target="#dialog-target"
|
||||||
|
width="560px"
|
||||||
|
close={() => setDetailsClient(null)}
|
||||||
|
footerTemplate={() => (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<ButtonComponent onClick={() => setDetailsClient(null)}>{'Schließen'}</ButtonComponent>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DetailsContent client={detailsClient} groupIdToName={groupIdToName} />
|
||||||
|
</DialogComponent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bestätigungs-Dialog für Löschen */}
|
||||||
|
{showDialog && deleteClientId && (
|
||||||
|
<DialogComponent
|
||||||
|
visible={showDialog}
|
||||||
|
header="Bestätigung"
|
||||||
|
content="Möchten Sie diesen Client wirklich entfernen?"
|
||||||
|
showCloseIcon={true}
|
||||||
|
width="400px"
|
||||||
|
target="#dialog-target"
|
||||||
|
buttons={[
|
||||||
|
{ click: confirmDelete, buttonModel: { content: 'Ja', isPrimary: true } },
|
||||||
|
{ click: cancelDelete, buttonModel: { content: 'Abbrechen' } },
|
||||||
|
]}
|
||||||
|
close={cancelDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Clients;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { DatePickerComponent, TimePickerComponent } from '@syncfusion/ej2-react-
|
|||||||
import { DropDownListComponent, MultiSelectComponent } from '@syncfusion/ej2-react-dropdowns';
|
import { DropDownListComponent, MultiSelectComponent } from '@syncfusion/ej2-react-dropdowns';
|
||||||
import { CheckBoxComponent } from '@syncfusion/ej2-react-buttons';
|
import { CheckBoxComponent } from '@syncfusion/ej2-react-buttons';
|
||||||
import CustomSelectUploadEventModal from './CustomSelectUploadEventModal';
|
import CustomSelectUploadEventModal from './CustomSelectUploadEventModal';
|
||||||
|
import { updateEvent, detachEventOccurrence } from '../apiEvents';
|
||||||
|
// Holiday exceptions are now created in the backend
|
||||||
|
|
||||||
type CustomEventData = {
|
type CustomEventData = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -17,14 +19,33 @@ type CustomEventData = {
|
|||||||
weekdays: number[];
|
weekdays: number[];
|
||||||
repeatUntil: Date | null;
|
repeatUntil: Date | null;
|
||||||
skipHolidays: boolean;
|
skipHolidays: boolean;
|
||||||
|
media?: { id: string; path: string; name: string } | null;
|
||||||
|
slideshowInterval?: number;
|
||||||
|
pageProgress?: boolean;
|
||||||
|
autoProgress?: boolean;
|
||||||
|
websiteUrl?: string;
|
||||||
|
// Video-specific fields
|
||||||
|
autoplay?: boolean;
|
||||||
|
loop?: boolean;
|
||||||
|
volume?: number;
|
||||||
|
muted?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Typ für initialData erweitern, damit Id unterstützt wird
|
||||||
type CustomEventModalProps = {
|
type CustomEventModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (eventData: any) => void;
|
onSave: (eventData: CustomEventData) => void;
|
||||||
initialData?: any;
|
initialData?: Partial<CustomEventData> & {
|
||||||
groupName: string | { id: string | null; name: string }; // <- angepasst
|
Id?: string;
|
||||||
|
OccurrenceOfId?: string;
|
||||||
|
isSingleOccurrence?: boolean;
|
||||||
|
occurrenceDate?: Date;
|
||||||
|
};
|
||||||
|
groupName: string | { id: string | null; name: string };
|
||||||
|
groupColor?: string;
|
||||||
|
editMode?: boolean;
|
||||||
|
// Removed unused blockHolidays and isHolidayRange
|
||||||
};
|
};
|
||||||
|
|
||||||
const weekdayOptions = [
|
const weekdayOptions = [
|
||||||
@@ -50,7 +71,9 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
initialData = {},
|
initialData = {},
|
||||||
groupName, // <--- NEU
|
groupName,
|
||||||
|
groupColor,
|
||||||
|
editMode,
|
||||||
}) => {
|
}) => {
|
||||||
const [title, setTitle] = React.useState(initialData.title || '');
|
const [title, setTitle] = React.useState(initialData.title || '');
|
||||||
const [startDate, setStartDate] = React.useState(initialData.startDate || null);
|
const [startDate, setStartDate] = React.useState(initialData.startDate || null);
|
||||||
@@ -60,47 +83,127 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
const [endTime, setEndTime] = React.useState(initialData.endTime || new Date(0, 0, 0, 9, 30));
|
const [endTime, setEndTime] = React.useState(initialData.endTime || new Date(0, 0, 0, 9, 30));
|
||||||
const [type, setType] = React.useState(initialData.type ?? 'presentation');
|
const [type, setType] = React.useState(initialData.type ?? 'presentation');
|
||||||
const [description, setDescription] = React.useState(initialData.description || '');
|
const [description, setDescription] = React.useState(initialData.description || '');
|
||||||
const [repeat, setRepeat] = React.useState(initialData.repeat || false);
|
// Initialize recurrence state - force to false/empty for single occurrence editing
|
||||||
const [weekdays, setWeekdays] = React.useState<number[]>(initialData.weekdays || []);
|
const isSingleOccurrence = initialData.isSingleOccurrence || false;
|
||||||
const [repeatUntil, setRepeatUntil] = React.useState(initialData.repeatUntil || null);
|
const [repeat, setRepeat] = React.useState(isSingleOccurrence ? false : (initialData.repeat || false));
|
||||||
const [skipHolidays, setSkipHolidays] = React.useState(initialData.skipHolidays || false);
|
const [weekdays, setWeekdays] = React.useState<number[]>(isSingleOccurrence ? [] : (initialData.weekdays || []));
|
||||||
|
const [repeatUntil, setRepeatUntil] = React.useState(isSingleOccurrence ? null : (initialData.repeatUntil || null));
|
||||||
|
// Default to true so recurrences skip holidays by default, but false for single occurrences
|
||||||
|
const [skipHolidays, setSkipHolidays] = React.useState(
|
||||||
|
isSingleOccurrence ? false : (initialData.skipHolidays !== undefined ? initialData.skipHolidays : true)
|
||||||
|
);
|
||||||
const [errors, setErrors] = React.useState<{ [key: string]: string }>({});
|
const [errors, setErrors] = React.useState<{ [key: string]: string }>({});
|
||||||
const [media, setMedia] = React.useState<{ id: string; path: string; name: string } | null>(null);
|
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
|
||||||
const [pendingMedia, setPendingMedia] = React.useState<{
|
const [media, setMedia] = React.useState<{ id: string; path: string; name: string } | null>(
|
||||||
id: string;
|
initialData.media ?? null
|
||||||
path: string;
|
);
|
||||||
name: string;
|
// General settings state for presentation
|
||||||
} | null>(null);
|
// Removed unused generalLoaded and setGeneralLoaded
|
||||||
const [slideshowInterval, setSlideshowInterval] = React.useState<number>(10);
|
// Removed unused generalLoaded/generalSlideshowInterval/generalPageProgress/generalAutoProgress
|
||||||
const [websiteUrl, setWebsiteUrl] = React.useState<string>('');
|
|
||||||
|
// Per-event state
|
||||||
|
const [slideshowInterval, setSlideshowInterval] = React.useState<number>(
|
||||||
|
initialData.slideshowInterval ?? 10
|
||||||
|
);
|
||||||
|
const [pageProgress, setPageProgress] = React.useState<boolean>(
|
||||||
|
initialData.pageProgress ?? true
|
||||||
|
);
|
||||||
|
const [autoProgress, setAutoProgress] = React.useState<boolean>(
|
||||||
|
initialData.autoProgress ?? true
|
||||||
|
);
|
||||||
|
const [websiteUrl, setWebsiteUrl] = React.useState<string>(initialData.websiteUrl ?? '');
|
||||||
|
|
||||||
|
// Video-specific state with system defaults loading
|
||||||
|
const [autoplay, setAutoplay] = React.useState<boolean>(initialData.autoplay ?? true);
|
||||||
|
const [loop, setLoop] = React.useState<boolean>(initialData.loop ?? true);
|
||||||
|
const [volume, setVolume] = React.useState<number>(initialData.volume ?? 0.8);
|
||||||
|
const [muted, setMuted] = React.useState<boolean>(initialData.muted ?? false);
|
||||||
|
const [videoDefaultsLoaded, setVideoDefaultsLoaded] = React.useState<boolean>(false);
|
||||||
|
const [isSaving, setIsSaving] = React.useState(false);
|
||||||
|
|
||||||
const [mediaModalOpen, setMediaModalOpen] = React.useState(false);
|
const [mediaModalOpen, setMediaModalOpen] = React.useState(false);
|
||||||
|
|
||||||
|
// Load system video defaults once when opening for a new video event
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open && !editMode && !videoDefaultsLoaded) {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const api = await import('../apiSystemSettings');
|
||||||
|
const keys = ['video_autoplay', 'video_loop', 'video_volume', 'video_muted'] as const;
|
||||||
|
const [autoplayRes, loopRes, volumeRes, mutedRes] = await Promise.all(
|
||||||
|
keys.map(k => api.getSetting(k).catch(() => ({ value: null } as { value: string | null })))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only apply defaults if not already set from initialData
|
||||||
|
if (initialData.autoplay === undefined) {
|
||||||
|
setAutoplay(autoplayRes.value == null ? true : autoplayRes.value === 'true');
|
||||||
|
}
|
||||||
|
if (initialData.loop === undefined) {
|
||||||
|
setLoop(loopRes.value == null ? true : loopRes.value === 'true');
|
||||||
|
}
|
||||||
|
if (initialData.volume === undefined) {
|
||||||
|
const volParsed = volumeRes.value == null ? 0.8 : parseFloat(String(volumeRes.value));
|
||||||
|
setVolume(Number.isFinite(volParsed) ? volParsed : 0.8);
|
||||||
|
}
|
||||||
|
if (initialData.muted === undefined) {
|
||||||
|
setMuted(mutedRes.value == null ? false : mutedRes.value === 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
setVideoDefaultsLoaded(true);
|
||||||
|
} catch {
|
||||||
|
// Silently fall back to hard-coded defaults
|
||||||
|
setVideoDefaultsLoaded(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}, [open, editMode, videoDefaultsLoaded, initialData]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
const isSingleOccurrence = initialData.isSingleOccurrence || false;
|
||||||
|
|
||||||
setTitle(initialData.title || '');
|
setTitle(initialData.title || '');
|
||||||
setStartDate(initialData.startDate || null);
|
setStartDate(initialData.startDate || null);
|
||||||
setStartTime(initialData.startTime || new Date(0, 0, 0, 9, 0));
|
setStartTime(initialData.startTime || new Date(0, 0, 0, 9, 0));
|
||||||
setEndTime(initialData.endTime || new Date(0, 0, 0, 9, 30));
|
setEndTime(initialData.endTime || new Date(0, 0, 0, 9, 30));
|
||||||
setType(initialData.type ?? 'presentation'); // Immer 'presentation' als Default
|
setType(initialData.type ?? 'presentation');
|
||||||
setDescription(initialData.description || '');
|
setDescription(initialData.description || '');
|
||||||
|
|
||||||
|
// For single occurrence editing, force recurrence settings to be disabled
|
||||||
|
if (isSingleOccurrence) {
|
||||||
|
setRepeat(false);
|
||||||
|
setWeekdays([]);
|
||||||
|
setRepeatUntil(null);
|
||||||
|
setSkipHolidays(false);
|
||||||
|
} else {
|
||||||
setRepeat(initialData.repeat || false);
|
setRepeat(initialData.repeat || false);
|
||||||
setWeekdays(initialData.weekdays || []);
|
setWeekdays(initialData.weekdays || []);
|
||||||
setRepeatUntil(initialData.repeatUntil || null);
|
setRepeatUntil(initialData.repeatUntil || null);
|
||||||
setSkipHolidays(initialData.skipHolidays || false);
|
setSkipHolidays(initialData.skipHolidays !== undefined ? initialData.skipHolidays : true);
|
||||||
setMedia(null);
|
|
||||||
setSlideshowInterval(10);
|
|
||||||
setWebsiteUrl('');
|
|
||||||
}
|
}
|
||||||
}, [open, initialData]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
|
||||||
if (!mediaModalOpen && pendingMedia) {
|
setMedia(initialData.media ?? null);
|
||||||
setMedia(pendingMedia);
|
setSlideshowInterval(initialData.slideshowInterval ?? 10);
|
||||||
setPendingMedia(null);
|
setPageProgress(initialData.pageProgress ?? true);
|
||||||
|
setAutoProgress(initialData.autoProgress ?? true);
|
||||||
|
setWebsiteUrl(initialData.websiteUrl ?? '');
|
||||||
|
|
||||||
|
// Video fields - use initialData values when editing
|
||||||
|
if (editMode) {
|
||||||
|
setAutoplay(initialData.autoplay ?? true);
|
||||||
|
setLoop(initialData.loop ?? true);
|
||||||
|
setVolume(initialData.volume ?? 0.8);
|
||||||
|
setMuted(initialData.muted ?? false);
|
||||||
}
|
}
|
||||||
}, [mediaModalOpen, pendingMedia]);
|
}
|
||||||
|
}, [open, initialData, editMode]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
if (isSaving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const newErrors: { [key: string]: string } = {};
|
const newErrors: { [key: string]: string } = {};
|
||||||
if (!title.trim()) newErrors.title = 'Titel ist erforderlich';
|
if (!title.trim()) newErrors.title = 'Titel ist erforderlich';
|
||||||
if (!startDate) newErrors.startDate = 'Startdatum ist erforderlich';
|
if (!startDate) newErrors.startDate = 'Startdatum ist erforderlich';
|
||||||
@@ -108,6 +211,30 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
if (!endTime) newErrors.endTime = 'Endzeit ist erforderlich';
|
if (!endTime) newErrors.endTime = 'Endzeit ist erforderlich';
|
||||||
if (!type) newErrors.type = 'Termintyp ist erforderlich';
|
if (!type) newErrors.type = 'Termintyp ist erforderlich';
|
||||||
|
|
||||||
|
// Vergangenheitsprüfung - für Einzeltermine strikt, für Serien flexibler
|
||||||
|
const startDateTime =
|
||||||
|
startDate && startTime
|
||||||
|
? new Date(
|
||||||
|
startDate.getFullYear(),
|
||||||
|
startDate.getMonth(),
|
||||||
|
startDate.getDate(),
|
||||||
|
startTime.getHours(),
|
||||||
|
startTime.getMinutes()
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const isPast = startDateTime && startDateTime < new Date();
|
||||||
|
|
||||||
|
if (isPast) {
|
||||||
|
if (isSingleOccurrence || !repeat) {
|
||||||
|
// Einzeltermine oder nicht-wiederkehrende Events dürfen nicht in der Vergangenheit liegen
|
||||||
|
newErrors.startDate = 'Termine in der Vergangenheit sind nicht erlaubt!';
|
||||||
|
} else if (repeat && repeatUntil && repeatUntil < new Date()) {
|
||||||
|
// Wiederkehrende Events sind erlaubt, wenn das End-Datum in der Zukunft liegt
|
||||||
|
newErrors.repeatUntil = 'Terminserien mit End-Datum in der Vergangenheit sind nicht erlaubt!';
|
||||||
|
}
|
||||||
|
// Andernfalls: Wiederkehrende Serie ohne End-Datum oder mit End-Datum in der Zukunft ist erlaubt
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'presentation') {
|
if (type === 'presentation') {
|
||||||
if (!media) newErrors.media = 'Bitte eine Präsentation auswählen';
|
if (!media) newErrors.media = 'Bitte eine Präsentation auswählen';
|
||||||
if (!slideshowInterval || slideshowInterval < 1)
|
if (!slideshowInterval || slideshowInterval < 1)
|
||||||
@@ -116,19 +243,50 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
if (type === 'website') {
|
if (type === 'website') {
|
||||||
if (!websiteUrl.trim()) newErrors.websiteUrl = 'Webseiten-URL ist erforderlich';
|
if (!websiteUrl.trim()) newErrors.websiteUrl = 'Webseiten-URL ist erforderlich';
|
||||||
}
|
}
|
||||||
|
if (type === 'video') {
|
||||||
|
if (!media) newErrors.media = 'Bitte ein Video auswählen';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedMediaId = media?.id ? Number(media.id) : null;
|
||||||
|
if (
|
||||||
|
(type === 'presentation' || type === 'video') &&
|
||||||
|
(!Number.isFinite(parsedMediaId) || (parsedMediaId as number) <= 0)
|
||||||
|
) {
|
||||||
|
newErrors.media = 'Ausgewähltes Medium ist ungültig. Bitte Datei erneut auswählen.';
|
||||||
|
}
|
||||||
|
// Holiday blocking logic removed (blockHolidays, isHolidayRange no longer used)
|
||||||
|
|
||||||
if (Object.keys(newErrors).length > 0) {
|
if (Object.keys(newErrors).length > 0) {
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setErrors({});
|
setIsSaving(true);
|
||||||
|
|
||||||
// group_id ist jetzt wirklich die ID (z.B. als prop übergeben)
|
|
||||||
const group_id = typeof groupName === 'object' && groupName !== null ? groupName.id : groupName;
|
const group_id = typeof groupName === 'object' && groupName !== null ? groupName.id : groupName;
|
||||||
|
|
||||||
// Daten für das Backend zusammenstellen
|
// Build recurrence rule if repeat is enabled
|
||||||
const payload: any = {
|
let recurrenceRule = null;
|
||||||
|
let recurrenceEnd = null;
|
||||||
|
if (repeat && weekdays.length > 0) {
|
||||||
|
// Convert weekdays to RRULE format (0=Monday -> MO)
|
||||||
|
const rruleDays = weekdays.map(day => {
|
||||||
|
const dayNames = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'];
|
||||||
|
return dayNames[day];
|
||||||
|
}).join(',');
|
||||||
|
|
||||||
|
recurrenceRule = `FREQ=WEEKLY;BYDAY=${rruleDays}`;
|
||||||
|
if (repeatUntil) {
|
||||||
|
const untilDate = new Date(repeatUntil);
|
||||||
|
untilDate.setHours(23, 59, 59);
|
||||||
|
recurrenceEnd = untilDate.toISOString();
|
||||||
|
// Note: RRULE UNTIL should be in UTC format for consistency
|
||||||
|
const untilUTC = untilDate.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||||
|
recurrenceRule += `;UNTIL=${untilUTC}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: CustomEventData & { [key: string]: unknown } = {
|
||||||
group_id,
|
group_id,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -152,49 +310,156 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
endTime.getMinutes()
|
endTime.getMinutes()
|
||||||
).toISOString()
|
).toISOString()
|
||||||
: null,
|
: null,
|
||||||
|
type,
|
||||||
|
startDate,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
repeat: isSingleOccurrence ? false : repeat,
|
||||||
|
weekdays: isSingleOccurrence ? [] : weekdays,
|
||||||
|
repeatUntil: isSingleOccurrence ? null : repeatUntil,
|
||||||
|
skipHolidays: isSingleOccurrence ? false : skipHolidays,
|
||||||
event_type: type,
|
event_type: type,
|
||||||
is_active: 1,
|
is_active: 1,
|
||||||
created_by: 1,
|
created_by: 1,
|
||||||
|
recurrence_rule: isSingleOccurrence ? null : recurrenceRule,
|
||||||
|
recurrence_end: isSingleOccurrence ? null : recurrenceEnd,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (type === 'presentation') {
|
if (type === 'presentation') {
|
||||||
payload.event_media_id = media?.id;
|
payload.event_media_id = parsedMediaId as number;
|
||||||
payload.slideshow_interval = slideshowInterval;
|
payload.slideshow_interval = slideshowInterval;
|
||||||
|
payload.page_progress = pageProgress;
|
||||||
|
payload.auto_progress = autoProgress;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'website') {
|
if (type === 'website') {
|
||||||
payload.website_url = websiteUrl;
|
payload.website_url = websiteUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === 'video') {
|
||||||
|
payload.event_media_id = parsedMediaId as number;
|
||||||
|
payload.autoplay = autoplay;
|
||||||
|
payload.loop = loop;
|
||||||
|
payload.volume = volume;
|
||||||
|
payload.muted = muted;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/events', {
|
let res;
|
||||||
|
if (editMode && initialData && typeof initialData.Id === 'string') {
|
||||||
|
// Check if this is a recurring event occurrence that should be detached
|
||||||
|
const shouldDetach = isSingleOccurrence &&
|
||||||
|
initialData.OccurrenceOfId &&
|
||||||
|
!isNaN(Number(initialData.OccurrenceOfId));
|
||||||
|
|
||||||
|
if (shouldDetach) {
|
||||||
|
// DETACH single occurrence from recurring series
|
||||||
|
|
||||||
|
// Use occurrenceDate from initialData, or fall back to startDate
|
||||||
|
const sourceDate = initialData.occurrenceDate || startDate;
|
||||||
|
if (!sourceDate) {
|
||||||
|
setErrors({ api: 'Fehler: Kein Datum für Einzeltermin verfügbar' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const occurrenceDate = sourceDate instanceof Date
|
||||||
|
? sourceDate.toISOString().split('T')[0]
|
||||||
|
: new Date(sourceDate).toISOString().split('T')[0];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the master event ID (OccurrenceOfId) for detaching
|
||||||
|
const masterId = Number(initialData.OccurrenceOfId);
|
||||||
|
res = await detachEventOccurrence(masterId, occurrenceDate, payload);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Detach operation failed:', error);
|
||||||
|
setErrors({ api: `API Fehler: ${error instanceof Error ? error.message : String(error)}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// UPDATE entire event/series OR standalone event
|
||||||
|
res = await updateEvent(initialData.Id, payload);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// CREATE
|
||||||
|
const createResponse = await fetch('/api/events', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
|
||||||
onSave(payload); // <--- HIER ergänzen!
|
let createData: { success?: boolean; error?: string } = {};
|
||||||
onClose();
|
try {
|
||||||
|
createData = await createResponse.json();
|
||||||
|
} catch {
|
||||||
|
createData = { error: `HTTP ${createResponse.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!createResponse.ok) {
|
||||||
|
setErrors({
|
||||||
|
api:
|
||||||
|
createData.error ||
|
||||||
|
`Fehler beim Speichern (HTTP ${createResponse.status})`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res = createData;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
onSave(payload);
|
||||||
|
onClose(); // <--- Box nach erfolgreichem Speichern schließen
|
||||||
} else {
|
} else {
|
||||||
const err = await res.json();
|
setErrors({ api: res.error || 'Fehler beim Speichern' });
|
||||||
setErrors({ api: err.error || 'Fehler beim Speichern' });
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setErrors({ api: 'Netzwerkfehler beim Speichern' });
|
setErrors({ api: 'Netzwerkfehler beim Speichern' });
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
console.log('handleSave called');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Vergangenheitsprüfung (außerhalb von handleSave, damit im Render verfügbar)
|
||||||
|
const startDateTime =
|
||||||
|
startDate && startTime
|
||||||
|
? new Date(
|
||||||
|
startDate.getFullYear(),
|
||||||
|
startDate.getMonth(),
|
||||||
|
startDate.getDate(),
|
||||||
|
startTime.getHours(),
|
||||||
|
startTime.getMinutes()
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const isPast = !!(startDateTime && startDateTime < new Date());
|
||||||
|
|
||||||
|
// Button sollte nur für Einzeltermine in der Vergangenheit deaktiviert werden
|
||||||
|
// Wiederkehrende Serien können bearbeitet werden, auch wenn der Starttermin vergangen ist
|
||||||
|
const shouldDisableButton = isPast && (isSingleOccurrence || !repeat);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogComponent
|
<DialogComponent
|
||||||
target="#root"
|
target="#root"
|
||||||
visible={open}
|
visible={open}
|
||||||
width="800px"
|
width="800px"
|
||||||
header={() => (
|
header={() => (
|
||||||
<div>
|
<div
|
||||||
Neuen Termin anlegen
|
style={{
|
||||||
|
background: groupColor || '#f5f5f5',
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '6px 6px 0 0',
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 20,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{editMode
|
||||||
|
? (isSingleOccurrence ? 'Einzeltermin bearbeiten' : 'Terminserie bearbeiten')
|
||||||
|
: 'Neuen Termin anlegen'}
|
||||||
{groupName && (
|
{groupName && (
|
||||||
<span style={{ fontWeight: 400, fontSize: 16, marginLeft: 16, color: '#888' }}>
|
<span style={{ fontWeight: 400, fontSize: 16, marginLeft: 16, color: '#fff' }}>
|
||||||
für Raumgruppe: <b>{typeof groupName === 'object' ? groupName.name : groupName}</b>
|
für Raumgruppe: <b>{typeof groupName === 'object' ? groupName.name : groupName}</b>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -208,13 +473,32 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
<button className="e-btn e-danger" onClick={onClose}>
|
<button className="e-btn e-danger" onClick={onClose}>
|
||||||
Schließen
|
Schließen
|
||||||
</button>
|
</button>
|
||||||
<button className="e-btn e-success" onClick={handleSave}>
|
<button
|
||||||
Termin(e) speichern
|
className="e-btn e-success"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={shouldDisableButton || isSaving} // <--- Button nur für Einzeltermine in Vergangenheit deaktivieren
|
||||||
|
>
|
||||||
|
{isSaving ? 'Speichert...' : 'Termin(e) speichern'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div style={{ padding: '24px' }}>
|
<div style={{ padding: '24px' }}>
|
||||||
|
{errors.api && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 12,
|
||||||
|
color: '#721c24',
|
||||||
|
background: '#f8d7da',
|
||||||
|
border: '1px solid #f5c6cb',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{errors.api}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
|
||||||
<div style={{ flex: 1, minWidth: 260 }}>
|
<div style={{ flex: 1, minWidth: 260 }}>
|
||||||
{/* ...Titel, Beschreibung, Datum, Zeit... */}
|
{/* ...Titel, Beschreibung, Datum, Zeit... */}
|
||||||
@@ -246,6 +530,38 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
{errors.startDate && (
|
{errors.startDate && (
|
||||||
<div style={{ color: 'red', fontSize: 12 }}>{errors.startDate}</div>
|
<div style={{ color: 'red', fontSize: 12 }}>{errors.startDate}</div>
|
||||||
)}
|
)}
|
||||||
|
{isPast && (isSingleOccurrence || !repeat) && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'orange',
|
||||||
|
fontWeight: 600,
|
||||||
|
marginLeft: 8,
|
||||||
|
display: 'inline-block',
|
||||||
|
background: '#fff3cd',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '2px 8px',
|
||||||
|
border: '1px solid #ffeeba',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚠️ Termin liegt in der Vergangenheit!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isPast && repeat && !isSingleOccurrence && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'blue',
|
||||||
|
fontWeight: 600,
|
||||||
|
marginLeft: 8,
|
||||||
|
display: 'inline-block',
|
||||||
|
background: '#e3f2fd',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '2px 8px',
|
||||||
|
border: '1px solid #90caf9',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ℹ️ Bearbeitung einer Terminserie (Startdatum kann in Vergangenheit liegen)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
@@ -276,11 +592,19 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 260 }}>
|
<div style={{ flex: 1, minWidth: 260 }}>
|
||||||
{/* ...Wiederholung, MultiSelect, Wiederholung bis, Ferien... */}
|
{/* ...Wiederholung, MultiSelect, Wiederholung bis, Ferien... */}
|
||||||
|
{isSingleOccurrence && (
|
||||||
|
<div style={{ marginBottom: 12, padding: '8px 12px', backgroundColor: '#e8f4fd', borderRadius: 4, border: '1px solid #bee5eb' }}>
|
||||||
|
<span style={{ fontSize: '14px', color: '#0c5460', fontWeight: 500 }}>
|
||||||
|
ℹ️ Bearbeitung eines Einzeltermins - Wiederholungsoptionen nicht verfügbar
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<CheckBoxComponent
|
<CheckBoxComponent
|
||||||
label="Wiederholender Termin"
|
label="Wiederholender Termin"
|
||||||
checked={repeat}
|
checked={repeat}
|
||||||
change={e => setRepeat(e.checked)}
|
change={e => setRepeat(e.checked)}
|
||||||
|
disabled={isSingleOccurrence}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
@@ -291,7 +615,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
placeholder="Wochentage"
|
placeholder="Wochentage"
|
||||||
value={weekdays}
|
value={weekdays}
|
||||||
change={e => setWeekdays(e.value as number[])}
|
change={e => setWeekdays(e.value as number[])}
|
||||||
disabled={!repeat}
|
disabled={!repeat || isSingleOccurrence}
|
||||||
showDropDownIcon={true}
|
showDropDownIcon={true}
|
||||||
closePopupOnSelect={false}
|
closePopupOnSelect={false}
|
||||||
/>
|
/>
|
||||||
@@ -303,7 +627,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
floatLabelType="Auto"
|
floatLabelType="Auto"
|
||||||
value={repeatUntil ?? undefined}
|
value={repeatUntil ?? undefined}
|
||||||
change={e => setRepeatUntil(e.value)}
|
change={e => setRepeatUntil(e.value)}
|
||||||
disabled={!repeat}
|
disabled={!repeat || isSingleOccurrence}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
@@ -311,7 +635,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
label="Ferientage berücksichtigen"
|
label="Ferientage berücksichtigen"
|
||||||
checked={skipHolidays}
|
checked={skipHolidays}
|
||||||
change={e => setSkipHolidays(e.checked)}
|
change={e => setSkipHolidays(e.checked)}
|
||||||
disabled={!repeat}
|
disabled={!repeat || isSingleOccurrence}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -353,6 +677,10 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
<span style={{ color: '#888' }}>Kein Medium ausgewählt</span>
|
<span style={{ color: '#888' }}>Kein Medium ausgewählt</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{errors.media && <div style={{ color: 'red', fontSize: 12 }}>{errors.media}</div>}
|
||||||
|
{errors.slideshowInterval && (
|
||||||
|
<div style={{ color: 'red', fontSize: 12 }}>{errors.slideshowInterval}</div>
|
||||||
|
)}
|
||||||
<TextBoxComponent
|
<TextBoxComponent
|
||||||
placeholder="Slideshow-Intervall (Sekunden)"
|
placeholder="Slideshow-Intervall (Sekunden)"
|
||||||
floatLabelType="Auto"
|
floatLabelType="Auto"
|
||||||
@@ -360,6 +688,20 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
value={String(slideshowInterval)}
|
value={String(slideshowInterval)}
|
||||||
change={e => setSlideshowInterval(Number(e.value))}
|
change={e => setSlideshowInterval(Number(e.value))}
|
||||||
/>
|
/>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<CheckBoxComponent
|
||||||
|
label="Seitenfortschritt anzeigen"
|
||||||
|
checked={pageProgress}
|
||||||
|
change={e => setPageProgress(e.checked || false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<CheckBoxComponent
|
||||||
|
label="Automatischer Fortschritt"
|
||||||
|
checked={autoProgress}
|
||||||
|
change={e => setAutoProgress(e.checked || false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{type === 'website' && (
|
{type === 'website' && (
|
||||||
@@ -372,6 +714,62 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{type === 'video' && (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 8, marginTop: 16 }}>
|
||||||
|
<button
|
||||||
|
className="e-btn"
|
||||||
|
onClick={() => setMediaModalOpen(true)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
Video auswählen/hochladen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<b>Ausgewähltes Video:</b>{' '}
|
||||||
|
{media ? (
|
||||||
|
media.path
|
||||||
|
) : (
|
||||||
|
<span style={{ color: '#888' }}>Kein Video ausgewählt</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{errors.media && <div style={{ color: 'red', fontSize: 12 }}>{errors.media}</div>}
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<CheckBoxComponent
|
||||||
|
label="Automatisch abspielen"
|
||||||
|
checked={autoplay}
|
||||||
|
change={e => setAutoplay(e.checked || false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<CheckBoxComponent
|
||||||
|
label="In Schleife abspielen"
|
||||||
|
checked={loop}
|
||||||
|
change={e => setLoop(e.checked || false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500, fontSize: '14px' }}>
|
||||||
|
Lautstärke
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<TextBoxComponent
|
||||||
|
placeholder="0.0 - 1.0"
|
||||||
|
floatLabelType="Never"
|
||||||
|
type="number"
|
||||||
|
value={String(volume)}
|
||||||
|
change={e => setVolume(Math.max(0, Math.min(1, Number(e.value))))}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<CheckBoxComponent
|
||||||
|
label="Ton aus"
|
||||||
|
checked={muted}
|
||||||
|
change={e => setMuted(e.checked || false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -381,7 +779,13 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
open={mediaModalOpen}
|
open={mediaModalOpen}
|
||||||
onClose={() => setMediaModalOpen(false)}
|
onClose={() => setMediaModalOpen(false)}
|
||||||
onSelect={({ id, path, name }) => {
|
onSelect={({ id, path, name }) => {
|
||||||
setPendingMedia({ id, path, name });
|
setMedia({ id, path, name });
|
||||||
|
setErrors(prev => {
|
||||||
|
if (!prev.media) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.media;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setMediaModalOpen(false);
|
setMediaModalOpen(false);
|
||||||
}}
|
}}
|
||||||
selectedFileId={null}
|
selectedFileId={null}
|
||||||
|
|||||||
@@ -1,25 +1,56 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
interface CustomMediaInfoPanelProps {
|
interface CustomMediaInfoPanelProps {
|
||||||
mediaId: string;
|
name: string;
|
||||||
title: string;
|
size: number;
|
||||||
description: string;
|
type: string;
|
||||||
eventId?: string;
|
dateModified: number;
|
||||||
onSave: (data: { title: string; description: string; eventId?: string }) => void;
|
description?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CustomMediaInfoPanel: React.FC<CustomMediaInfoPanelProps> = ({
|
const CustomMediaInfoPanel: React.FC<CustomMediaInfoPanelProps> = ({
|
||||||
mediaId,
|
name,
|
||||||
title,
|
size,
|
||||||
|
type,
|
||||||
|
dateModified,
|
||||||
description,
|
description,
|
||||||
eventId,
|
|
||||||
onSave,
|
|
||||||
}) => {
|
}) => {
|
||||||
// Hier kannst du Formularfelder und Logik für die Bearbeitung einbauen
|
function formatLocalDate(timestamp: number | undefined | null) {
|
||||||
|
if (!timestamp || isNaN(timestamp)) return '-';
|
||||||
|
const date = new Date(timestamp * 1000);
|
||||||
|
return date.toLocaleString('de-DE');
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 16,
|
||||||
|
border: '1px solid #eee',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#fafafa',
|
||||||
|
maxWidth: 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ marginBottom: 12 }}>Datei-Eigenschaften</h3>
|
||||||
<div>
|
<div>
|
||||||
<h3>Medien-Informationen bearbeiten</h3>
|
<b>Name:</b> {name || '-'}
|
||||||
{/* Formularfelder für Titel, Beschreibung, Event-Zuordnung */}
|
</div>
|
||||||
|
<div>
|
||||||
|
<b>Typ:</b> {type || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<b>Größe:</b> {typeof size === 'number' && !isNaN(size) ? size + ' Bytes' : '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<b>Geändert:</b> {formatLocalDate(dateModified)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<b>Beschreibung:</b>{' '}
|
||||||
|
{description && description !== 'null' ? (
|
||||||
|
description
|
||||||
|
) : (
|
||||||
|
<span style={{ color: '#888' }}>Keine Beschreibung</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { useAuth } from '../useAuth';
|
||||||
import { DialogComponent } from '@syncfusion/ej2-react-popups';
|
import { DialogComponent } from '@syncfusion/ej2-react-popups';
|
||||||
import {
|
import {
|
||||||
FileManagerComponent,
|
FileManagerComponent,
|
||||||
@@ -19,12 +20,15 @@ type CustomSelectUploadEventModalProps = {
|
|||||||
|
|
||||||
const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps> = props => {
|
const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps> = props => {
|
||||||
const { open, onClose, onSelect } = props;
|
const { open, onClose, onSelect } = props;
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isSuperadmin = useMemo(() => user?.role === 'superadmin', [user]);
|
||||||
|
|
||||||
const [selectedFile, setSelectedFile] = useState<{
|
const [selectedFile, setSelectedFile] = useState<{
|
||||||
id: string;
|
id: string;
|
||||||
path: string;
|
path: string;
|
||||||
name: string;
|
name: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [selectionError, setSelectionError] = useState<string>('');
|
||||||
|
|
||||||
// Callback für Dateiauswahl
|
// Callback für Dateiauswahl
|
||||||
interface FileSelectEventArgs {
|
interface FileSelectEventArgs {
|
||||||
@@ -39,6 +43,7 @@ const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps>
|
|||||||
const handleFileSelect = async (args: FileSelectEventArgs) => {
|
const handleFileSelect = async (args: FileSelectEventArgs) => {
|
||||||
if (args.fileDetails.isFile && args.fileDetails.size > 0) {
|
if (args.fileDetails.isFile && args.fileDetails.size > 0) {
|
||||||
const filename = args.fileDetails.name;
|
const filename = args.fileDetails.name;
|
||||||
|
setSelectionError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
@@ -48,10 +53,13 @@ const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps>
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setSelectedFile({ id: data.id, path: data.file_path, name: filename });
|
setSelectedFile({ id: data.id, path: data.file_path, name: filename });
|
||||||
} else {
|
} else {
|
||||||
setSelectedFile({ id: filename, path: filename, name: filename });
|
setSelectedFile(null);
|
||||||
|
setSelectionError('Datei ist noch nicht als Medium registriert. Bitte erneut hochladen oder Metadaten prüfen.');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error fetching file details:', e);
|
console.error('Error fetching file details:', e);
|
||||||
|
setSelectedFile(null);
|
||||||
|
setSelectionError('Medium-ID konnte nicht geladen werden. Bitte erneut versuchen.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -63,6 +71,23 @@ const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps>
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FileItem = { name: string; isFile: boolean };
|
||||||
|
type ReadSuccessArgs = { action: string; result?: { files?: FileItem[] } };
|
||||||
|
type FileOpenArgs = { fileDetails?: FileItem; cancel?: boolean };
|
||||||
|
|
||||||
|
const handleSuccess = (args: ReadSuccessArgs) => {
|
||||||
|
if (isSuperadmin) return;
|
||||||
|
if (args && args.action === 'read' && args.result && Array.isArray(args.result.files)) {
|
||||||
|
args.result.files = args.result.files.filter((f: FileItem) => !(f.name === 'converted' && !f.isFile));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileOpen = (args: FileOpenArgs) => {
|
||||||
|
if (!isSuperadmin && args && args.fileDetails && args.fileDetails.name === 'converted' && !args.fileDetails.isFile) {
|
||||||
|
args.cancel = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogComponent
|
<DialogComponent
|
||||||
target="#root"
|
target="#root"
|
||||||
@@ -84,6 +109,9 @@ const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps>
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FileManagerComponent
|
<FileManagerComponent
|
||||||
|
cssClass="e-bigger media-icons-xl"
|
||||||
|
success={handleSuccess}
|
||||||
|
fileOpen={handleFileOpen}
|
||||||
ajaxSettings={{
|
ajaxSettings={{
|
||||||
url: hostUrl + 'operations',
|
url: hostUrl + 'operations',
|
||||||
getImageUrl: hostUrl + 'get-image',
|
getImageUrl: hostUrl + 'get-image',
|
||||||
@@ -112,6 +140,9 @@ const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps>
|
|||||||
>
|
>
|
||||||
<Inject services={[NavigationPane, DetailsView, Toolbar]} />
|
<Inject services={[NavigationPane, DetailsView, Toolbar]} />
|
||||||
</FileManagerComponent>
|
</FileManagerComponent>
|
||||||
|
{selectionError && (
|
||||||
|
<div style={{ marginTop: 10, color: '#b71c1c', fontSize: 13 }}>{selectionError}</div>
|
||||||
|
)}
|
||||||
</DialogComponent>
|
</DialogComponent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
22
dashboard/src/components/SetupModeButton.tsx
Normal file
22
dashboard/src/components/SetupModeButton.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Wrench } from 'lucide-react';
|
||||||
|
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
|
||||||
|
|
||||||
|
const SetupModeButton: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<ButtonComponent
|
||||||
|
cssClass="e-warning"
|
||||||
|
onClick={() => navigate('/setup')}
|
||||||
|
title="Erweiterungsmodus starten"
|
||||||
|
>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Wrench size={14.4} />
|
||||||
|
Erweiterungsmodus
|
||||||
|
</span>
|
||||||
|
</ButtonComponent>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SetupModeButton;
|
||||||
@@ -1,43 +1,937 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { fetchClients } from './apiClients';
|
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
|
||||||
import type { Client } from './apiClients';
|
import {
|
||||||
|
GridComponent,
|
||||||
|
ColumnsDirective,
|
||||||
|
ColumnDirective,
|
||||||
|
Inject,
|
||||||
|
Page,
|
||||||
|
Search,
|
||||||
|
Sort,
|
||||||
|
Toolbar,
|
||||||
|
} from '@syncfusion/ej2-react-grids';
|
||||||
|
import { DialogComponent } from '@syncfusion/ej2-react-popups';
|
||||||
|
import { MessageComponent, ToastComponent } from '@syncfusion/ej2-react-notifications';
|
||||||
|
import { fetchGroupsWithClients, restartClient } from './apiClients';
|
||||||
|
import type { Group, Client } from './apiClients';
|
||||||
|
import { fetchEvents } from './apiEvents';
|
||||||
|
import { listHolidays } from './apiHolidays';
|
||||||
|
import {
|
||||||
|
getAcademicPeriodForDate,
|
||||||
|
getActiveAcademicPeriod,
|
||||||
|
listAcademicPeriods,
|
||||||
|
type AcademicPeriod,
|
||||||
|
} from './apiAcademicPeriods';
|
||||||
|
import { getHolidayBannerSetting } from './apiSystemSettings';
|
||||||
|
import { formatIsoDateForDisplay } from './dateFormatting';
|
||||||
|
import { useAuth } from './useAuth';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
const Dashboard: React.FC = () => {
|
const REFRESH_INTERVAL_MS = 15000;
|
||||||
const [clients, setClients] = useState<Client[]>([]);
|
const UNASSIGNED_GROUP_NAME = 'Nicht zugeordnet';
|
||||||
|
|
||||||
useEffect(() => {
|
type DashboardFilter = 'all' | 'online' | 'offline' | 'warning';
|
||||||
fetchClients().then(setClients).catch(console.error);
|
|
||||||
}, []);
|
interface ActiveEvent {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
eventType: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
isRecurring: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupEvents = Record<number, ActiveEvent | null>;
|
||||||
|
|
||||||
|
interface GroupRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
totalClients: number;
|
||||||
|
onlineClients: number;
|
||||||
|
offlineClients: number;
|
||||||
|
healthLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClientRow {
|
||||||
|
uuid: string;
|
||||||
|
description: string;
|
||||||
|
groupName: string;
|
||||||
|
hostname: string;
|
||||||
|
ip: string;
|
||||||
|
isAlive: boolean;
|
||||||
|
aliveLabel: string;
|
||||||
|
lastAlive: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUtcDate(value?: string | null): Date | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const hasTimezone = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(value);
|
||||||
|
const normalized = hasTimezone ? value : `${value}Z`;
|
||||||
|
const parsed = new Date(normalized);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return null;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelative(value?: string | null): string {
|
||||||
|
const date = parseUtcDate(value);
|
||||||
|
if (!date) return 'Keine Daten';
|
||||||
|
|
||||||
|
const diffMs = Date.now() - date.getTime();
|
||||||
|
const diffMinutes = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMinutes / 60);
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
|
||||||
|
if (diffMinutes < 1) return 'gerade eben';
|
||||||
|
if (diffMinutes < 60) return `vor ${diffMinutes} Min.`;
|
||||||
|
if (diffHours < 24) return `vor ${diffHours} Std.`;
|
||||||
|
return `vor ${diffDays} Tag${diffDays === 1 ? '' : 'en'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventTypeLabel(type?: string): string {
|
||||||
|
switch ((type || '').toLowerCase()) {
|
||||||
|
case 'presentation':
|
||||||
|
return 'Präsentation';
|
||||||
|
case 'website':
|
||||||
|
return 'Website';
|
||||||
|
case 'video':
|
||||||
|
return 'Video';
|
||||||
|
case 'webuntis':
|
||||||
|
return 'WebUntis';
|
||||||
|
case 'message':
|
||||||
|
return 'Mitteilung';
|
||||||
|
default:
|
||||||
|
return 'Inhalt';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthBadge(online: number, total: number) {
|
||||||
|
const ratio = total === 0 ? 0 : online / total;
|
||||||
|
let bg = '#fee2e2';
|
||||||
|
let color = '#991b1b';
|
||||||
|
let label = 'Kritisch';
|
||||||
|
|
||||||
|
if (ratio === 1) {
|
||||||
|
bg = '#dcfce7';
|
||||||
|
color = '#166534';
|
||||||
|
label = 'Optimal';
|
||||||
|
} else if (ratio >= 0.5) {
|
||||||
|
bg = '#fef3c7';
|
||||||
|
color = '#92400e';
|
||||||
|
label = 'Teilweise';
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
backgroundColor: bg,
|
||||||
|
color,
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
display: 'inline-block',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function aliveBadge(isAlive: boolean) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
backgroundColor: isAlive ? '#dcfce7' : '#e2e8f0',
|
||||||
|
color: isAlive ? '#166534' : '#334155',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
display: 'inline-block',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isAlive ? 'Online' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Dashboard: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const toastRef = useRef<ToastComponent>(null);
|
||||||
|
const [groups, setGroups] = useState<Group[]>([]);
|
||||||
|
const [activeEvents, setActiveEvents] = useState<GroupEvents>({});
|
||||||
|
const [filter, setFilter] = useState<DashboardFilter>('all');
|
||||||
|
const [selectedGroupId, setSelectedGroupId] = useState<number | null>(null);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||||||
|
|
||||||
|
const [restartDialogGroup, setRestartDialogGroup] = useState<Group | null>(null);
|
||||||
|
const [restartBusy, setRestartBusy] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const [holidayBannerEnabled, setHolidayBannerEnabled] = useState<boolean>(true);
|
||||||
|
const [activePeriod, setActivePeriod] = useState<AcademicPeriod | null>(null);
|
||||||
|
const [holidayOverlapCount, setHolidayOverlapCount] = useState<number>(0);
|
||||||
|
const [holidayFirst, setHolidayFirst] = useState<string | null>(null);
|
||||||
|
const [holidayLast, setHolidayLast] = useState<string | null>(null);
|
||||||
|
const [holidayLoading, setHolidayLoading] = useState<boolean>(false);
|
||||||
|
const [holidayError, setHolidayError] = useState<string | null>(null);
|
||||||
|
const holidayRequestSeqRef = useRef(0);
|
||||||
|
const lastValidActivePeriodRef = useRef<AcademicPeriod | null>(null);
|
||||||
|
|
||||||
|
const isAdminOrHigher = user?.role === 'admin' || user?.role === 'superadmin';
|
||||||
|
|
||||||
|
const openHolidaySettings = useCallback(() => {
|
||||||
|
navigate('/einstellungen?focus=holidays');
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const isUnassignedGroup = useCallback((groupName: string) => {
|
||||||
|
return groupName.trim().toLowerCase() === UNASSIGNED_GROUP_NAME.toLowerCase();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadActiveEvents = useCallback(async (groupsList: Group[]) => {
|
||||||
|
const now = new Date();
|
||||||
|
const start = new Date(now.getTime() - 60000);
|
||||||
|
const end = new Date(now.getTime() + 60000);
|
||||||
|
|
||||||
|
const entries = await Promise.all(
|
||||||
|
groupsList.map(async group => {
|
||||||
|
try {
|
||||||
|
const events = await fetchEvents(String(group.id), false, { start, end, expand: true });
|
||||||
|
if (events && events.length > 0) {
|
||||||
|
const event = events[0];
|
||||||
|
return [
|
||||||
|
group.id,
|
||||||
|
{
|
||||||
|
id: event.id,
|
||||||
|
title: event.subject || 'Unbenannter Event',
|
||||||
|
eventType: event.type || 'unknown',
|
||||||
|
start: event.startTime,
|
||||||
|
end: event.endTime,
|
||||||
|
isRecurring: Boolean(event.recurrenceRule),
|
||||||
|
} as ActiveEvent,
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
return [group.id, null] as const;
|
||||||
|
} catch {
|
||||||
|
return [group.id, null] as const;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setActiveEvents(Object.fromEntries(entries));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadDashboard = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const groupsData = await fetchGroupsWithClients();
|
||||||
|
setGroups(groupsData);
|
||||||
|
setLastUpdate(new Date());
|
||||||
|
const selectableGroups = groupsData.filter(group => !isUnassignedGroup(group.name));
|
||||||
|
|
||||||
|
setSelectedGroupId(prevSelectedGroupId => {
|
||||||
|
if (prevSelectedGroupId === null) {
|
||||||
|
return selectableGroups[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedGroup = groupsData.find(group => group.id === prevSelectedGroupId);
|
||||||
|
if (!selectedGroup || isUnassignedGroup(selectedGroup.name)) {
|
||||||
|
return selectableGroups[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return prevSelectedGroupId;
|
||||||
|
});
|
||||||
|
|
||||||
|
void loadActiveEvents(groupsData);
|
||||||
|
} catch (loadError) {
|
||||||
|
setError(loadError instanceof Error ? loadError.message : 'Dashboard-Daten konnten nicht geladen werden');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [isUnassignedGroup, loadActiveEvents]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadDashboard();
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
void loadDashboard();
|
||||||
|
}, REFRESH_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [loadDashboard]);
|
||||||
|
|
||||||
|
const loadHolidayStatus = useCallback(async () => {
|
||||||
|
const requestSeq = ++holidayRequestSeqRef.current;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bannerSetting = await getHolidayBannerSetting();
|
||||||
|
if (requestSeq !== holidayRequestSeqRef.current) return;
|
||||||
|
setHolidayBannerEnabled(bannerSetting.enabled);
|
||||||
|
if (!bannerSetting.enabled) return;
|
||||||
|
} catch {
|
||||||
|
if (requestSeq !== holidayRequestSeqRef.current) return;
|
||||||
|
setHolidayBannerEnabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestSeq !== holidayRequestSeqRef.current) return;
|
||||||
|
setHolidayLoading(true);
|
||||||
|
setHolidayError(null);
|
||||||
|
try {
|
||||||
|
let period: AcademicPeriod | null = null;
|
||||||
|
let lookupFailed = false;
|
||||||
|
|
||||||
|
// 1) Prefer explicit active-period endpoint.
|
||||||
|
try {
|
||||||
|
period = await getActiveAcademicPeriod();
|
||||||
|
} catch {
|
||||||
|
lookupFailed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Fall back to list-based active flag (same logic as settings page).
|
||||||
|
if (!period) {
|
||||||
|
try {
|
||||||
|
const periods = await listAcademicPeriods({ includeArchived: true });
|
||||||
|
period = periods.find(candidate => candidate.isActive && !candidate.isArchived) || null;
|
||||||
|
} catch {
|
||||||
|
lookupFailed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Last resort: period covering today's date.
|
||||||
|
if (!period) {
|
||||||
|
try {
|
||||||
|
period = await getAcademicPeriodForDate(new Date());
|
||||||
|
} catch {
|
||||||
|
lookupFailed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestSeq !== holidayRequestSeqRef.current) return;
|
||||||
|
|
||||||
|
if (!period && lookupFailed) {
|
||||||
|
// Keep previously known period on transient lookup failures to avoid false "no period" UI.
|
||||||
|
if (lastValidActivePeriodRef.current) {
|
||||||
|
period = lastValidActivePeriodRef.current;
|
||||||
|
} else {
|
||||||
|
setHolidayError('Aktive Periode konnte derzeit nicht verifiziert werden. Bitte erneut aktualisieren.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (period) {
|
||||||
|
lastValidActivePeriodRef.current = period;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActivePeriod(prevPeriod => {
|
||||||
|
if (!period && !prevPeriod) return prevPeriod;
|
||||||
|
if (period && prevPeriod && prevPeriod.id === period.id) return prevPeriod;
|
||||||
|
return period || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!period) {
|
||||||
|
setHolidayOverlapCount(0);
|
||||||
|
setHolidayFirst(null);
|
||||||
|
setHolidayLast(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const holidayData = await listHolidays(undefined, period.id);
|
||||||
|
const list = holidayData.holidays || [];
|
||||||
|
const periodStart = new Date(`${period.startDate}T00:00:00`);
|
||||||
|
const periodEnd = new Date(`${period.endDate}T23:59:59`);
|
||||||
|
|
||||||
|
const overlapping = list.filter(holiday => {
|
||||||
|
const holidayStart = new Date(`${holiday.start_date}T00:00:00`);
|
||||||
|
const holidayEnd = new Date(`${holiday.end_date}T23:59:59`);
|
||||||
|
return holidayStart <= periodEnd && holidayEnd >= periodStart;
|
||||||
|
});
|
||||||
|
|
||||||
|
setHolidayOverlapCount(overlapping.length);
|
||||||
|
if (overlapping.length > 0) {
|
||||||
|
const sorted = overlapping.slice().sort((a, b) => a.start_date.localeCompare(b.start_date));
|
||||||
|
setHolidayFirst(sorted[0].start_date);
|
||||||
|
setHolidayLast(sorted[sorted.length - 1].end_date);
|
||||||
|
} else {
|
||||||
|
setHolidayFirst(null);
|
||||||
|
setHolidayLast(null);
|
||||||
|
}
|
||||||
|
} catch (holidayLoadError) {
|
||||||
|
if (requestSeq !== holidayRequestSeqRef.current) return;
|
||||||
|
setHolidayError(
|
||||||
|
holidayLoadError instanceof Error ? holidayLoadError.message : 'Ferienstatus konnte nicht geladen werden'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (requestSeq === holidayRequestSeqRef.current) {
|
||||||
|
setHolidayLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadHolidayStatus();
|
||||||
|
}, [loadHolidayStatus]);
|
||||||
|
|
||||||
|
const filteredGroups = useMemo(() => {
|
||||||
|
return groups.filter(group => {
|
||||||
|
const total = group.clients.length;
|
||||||
|
const online = group.clients.filter(client => client.is_alive).length;
|
||||||
|
const offline = total - online;
|
||||||
|
if (filter === 'online') return total > 0 && online === total;
|
||||||
|
if (filter === 'offline') return offline > 0;
|
||||||
|
if (filter === 'warning') return online > 0 && offline > 0;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [filter, groups]);
|
||||||
|
|
||||||
|
const unassignedGroup = useMemo(() => {
|
||||||
|
return groups.find(group => isUnassignedGroup(group.name)) || null;
|
||||||
|
}, [groups, isUnassignedGroup]);
|
||||||
|
|
||||||
|
const unassignedClientCount = unassignedGroup?.clients.length || 0;
|
||||||
|
const hasUnassignedClients = unassignedClientCount > 0;
|
||||||
|
|
||||||
|
const visibleFilteredGroups = useMemo(() => {
|
||||||
|
return filteredGroups.filter(group => !isUnassignedGroup(group.name));
|
||||||
|
}, [filteredGroups, isUnassignedGroup]);
|
||||||
|
|
||||||
|
const globalStats = useMemo(() => {
|
||||||
|
const allClients = groups.flatMap(group => group.clients);
|
||||||
|
const total = allClients.length;
|
||||||
|
const online = allClients.filter(client => client.is_alive).length;
|
||||||
|
const offline = total - online;
|
||||||
|
const warningGroups = groups.filter(group => {
|
||||||
|
if (isUnassignedGroup(group.name)) return false;
|
||||||
|
const totalClients = group.clients.length;
|
||||||
|
if (totalClients === 0) return false;
|
||||||
|
const onlineClients = group.clients.filter(client => client.is_alive).length;
|
||||||
|
return onlineClients > 0 && onlineClients < totalClients;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
const activeIncidents = groups.filter(group => {
|
||||||
|
if (isUnassignedGroup(group.name)) return false;
|
||||||
|
return group.clients.some(client => !client.is_alive);
|
||||||
|
}).length;
|
||||||
|
const onlineRatio = total === 0 ? 0 : Math.round((online / total) * 100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
online,
|
||||||
|
offline,
|
||||||
|
warningGroups,
|
||||||
|
activeIncidents,
|
||||||
|
onlineRatio,
|
||||||
|
};
|
||||||
|
}, [groups, isUnassignedGroup]);
|
||||||
|
|
||||||
|
const groupRows = useMemo<GroupRow[]>(() => {
|
||||||
|
return visibleFilteredGroups.map(group => {
|
||||||
|
const totalClients = group.clients.length;
|
||||||
|
const onlineClients = group.clients.filter(client => client.is_alive).length;
|
||||||
|
const offlineClients = totalClients - onlineClients;
|
||||||
|
const healthPercent = totalClients === 0 ? 0 : Math.round((onlineClients / totalClients) * 100);
|
||||||
|
const healthLabel =
|
||||||
|
healthPercent === 100 ? 'Optimal' : healthPercent >= 50 ? 'Teilweise' : 'Kritisch';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: group.id,
|
||||||
|
name: group.name,
|
||||||
|
totalClients,
|
||||||
|
onlineClients,
|
||||||
|
offlineClients,
|
||||||
|
healthLabel,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [visibleFilteredGroups]);
|
||||||
|
|
||||||
|
const clientRows = useMemo<ClientRow[]>(() => {
|
||||||
|
const byGroup = new Map<number, Group>(groups.map(group => [group.id, group]));
|
||||||
|
|
||||||
|
if (selectedGroupId && byGroup.has(selectedGroupId)) {
|
||||||
|
const selected = byGroup.get(selectedGroupId)!;
|
||||||
|
return selected.clients.map(client => ({
|
||||||
|
uuid: client.uuid,
|
||||||
|
description: client.description || 'Ohne Bezeichnung',
|
||||||
|
groupName: selected.name,
|
||||||
|
hostname: client.hostname || '-',
|
||||||
|
ip: client.ip || '-',
|
||||||
|
isAlive: Boolean(client.is_alive),
|
||||||
|
aliveLabel: client.is_alive ? 'Online' : 'Offline',
|
||||||
|
lastAlive: formatRelative(client.last_alive),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return visibleFilteredGroups.flatMap(group =>
|
||||||
|
group.clients.map(client => ({
|
||||||
|
uuid: client.uuid,
|
||||||
|
description: client.description || 'Ohne Bezeichnung',
|
||||||
|
groupName: group.name,
|
||||||
|
hostname: client.hostname || '-',
|
||||||
|
ip: client.ip || '-',
|
||||||
|
isAlive: Boolean(client.is_alive),
|
||||||
|
aliveLabel: client.is_alive ? 'Online' : 'Offline',
|
||||||
|
lastAlive: formatRelative(client.last_alive),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}, [groups, selectedGroupId, visibleFilteredGroups]);
|
||||||
|
|
||||||
|
const incidentGroups = useMemo(() => {
|
||||||
|
return visibleFilteredGroups
|
||||||
|
.map(group => {
|
||||||
|
const offlineClients = group.clients.filter(client => !client.is_alive);
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
offlineClients,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(item => item.offlineClients.length > 0)
|
||||||
|
.sort((a, b) => b.offlineClients.length - a.offlineClients.length);
|
||||||
|
}, [visibleFilteredGroups]);
|
||||||
|
|
||||||
|
const contentRows = useMemo(() => {
|
||||||
|
return visibleFilteredGroups.map(group => {
|
||||||
|
const activeEvent = activeEvents[group.id];
|
||||||
|
const online = group.clients.filter(client => client.is_alive).length;
|
||||||
|
return {
|
||||||
|
id: group.id,
|
||||||
|
groupName: group.name,
|
||||||
|
online,
|
||||||
|
total: group.clients.length,
|
||||||
|
title: activeEvent?.title || 'Kein aktiver Inhalt',
|
||||||
|
contentType: activeEvent ? eventTypeLabel(activeEvent.eventType) : '-',
|
||||||
|
recurring: activeEvent?.isRecurring ? 'Ja' : 'Nein',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [activeEvents, visibleFilteredGroups]);
|
||||||
|
|
||||||
|
const handleManualRefresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await Promise.all([loadDashboard(), loadHolidayStatus()]);
|
||||||
|
}, [loadDashboard, loadHolidayStatus]);
|
||||||
|
|
||||||
|
const openRestartDialog = useCallback((groupId: number) => {
|
||||||
|
const group = groups.find(item => item.id === groupId) || null;
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
const offlineCount = group.clients.filter(client => !client.is_alive).length;
|
||||||
|
if (offlineCount === 0) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
title: 'Keine Offline-Clients',
|
||||||
|
content: `Alle Infoscreens in ${group.name} sind online.`,
|
||||||
|
cssClass: 'e-toast-info',
|
||||||
|
icon: 'e-info toast-icons',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRestartDialogGroup(group);
|
||||||
|
}, [groups]);
|
||||||
|
|
||||||
|
const handleRestartGroupOffline = useCallback(async () => {
|
||||||
|
if (!restartDialogGroup) return;
|
||||||
|
const offlineClients = restartDialogGroup.clients.filter(client => !client.is_alive);
|
||||||
|
if (offlineClients.length === 0) {
|
||||||
|
setRestartDialogGroup(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRestartBusy(true);
|
||||||
|
const results = await Promise.allSettled(offlineClients.map(client => restartClient(client.uuid)));
|
||||||
|
const successCount = results.filter(result => result.status === 'fulfilled').length;
|
||||||
|
const failCount = results.length - successCount;
|
||||||
|
|
||||||
|
toastRef.current?.show({
|
||||||
|
title: 'Bulk-Neustart abgeschlossen',
|
||||||
|
content: `Gruppe ${restartDialogGroup.name}: ${successCount} erfolgreich, ${failCount} fehlgeschlagen`,
|
||||||
|
cssClass: failCount > 0 ? 'e-toast-warning' : 'e-toast-success',
|
||||||
|
icon: failCount > 0 ? 'e-warning toast-icons' : 'e-success toast-icons',
|
||||||
|
timeOut: 4000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setRestartBusy(false);
|
||||||
|
setRestartDialogGroup(null);
|
||||||
|
}, [restartDialogGroup]);
|
||||||
|
|
||||||
|
const headerButtons = (
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<ButtonComponent cssClass={filter === 'all' ? 'e-primary' : 'e-flat'} onClick={() => setFilter('all')}>
|
||||||
|
Alle ({groups.length})
|
||||||
|
</ButtonComponent>
|
||||||
|
<ButtonComponent cssClass={filter === 'online' ? 'e-primary' : 'e-flat'} onClick={() => setFilter('online')}>
|
||||||
|
Stabil
|
||||||
|
</ButtonComponent>
|
||||||
|
<ButtonComponent cssClass={filter === 'warning' ? 'e-primary' : 'e-flat'} onClick={() => setFilter('warning')}>
|
||||||
|
Warnung
|
||||||
|
</ButtonComponent>
|
||||||
|
<ButtonComponent cssClass={filter === 'offline' ? 'e-primary' : 'e-flat'} onClick={() => setFilter('offline')}>
|
||||||
|
Offline
|
||||||
|
</ButtonComponent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<ToastComponent ref={toastRef} position={{ X: 'Right', Y: 'Top' }} />
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 20, display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||||
<div>
|
<div>
|
||||||
<header className="mb-8 pb-4 border-b-2 border-[#d6c3a6]">
|
<h2 style={{ margin: 0, fontSize: 24, fontWeight: 600 }}>Dashboard</h2>
|
||||||
<h2 className="text-3xl font-extrabold mb-2">Dashboard</h2>
|
<p style={{ margin: '8px 0 0 0', color: '#6b7280' }}>
|
||||||
</header>
|
Systemstatus und aktive Inhalte für Benutzer, Redaktion und Administration
|
||||||
<h3 className="text-lg font-semibold mt-6 mb-4">Infoscreens</h3>
|
</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<p style={{ margin: '4px 0 0 0', fontSize: 12, color: '#94a3b8' }}>
|
||||||
{clients.map(client => (
|
Letzte Aktualisierung: {lastUpdate.toLocaleTimeString('de-DE')}
|
||||||
<div key={client.uuid} className="bg-white rounded shadow p-4 flex flex-col items-center">
|
</p>
|
||||||
<h4 className="text-lg font-bold mb-2">{client.location || 'Unbekannter Standort'}</h4>
|
|
||||||
<img
|
|
||||||
src={`/screenshots/${client.uuid}`}
|
|
||||||
alt={`Screenshot ${client.location}`}
|
|
||||||
className="w-full h-48 object-contain bg-gray-100 mb-2"
|
|
||||||
onError={e => (e.currentTarget.style.display = 'none')}
|
|
||||||
/>
|
|
||||||
<div className="text-sm text-gray-700 mb-1">
|
|
||||||
<span className="font-semibold">IP:</span> {client.ip_address}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-700">
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
<span className="font-semibold">Letztes Lebenszeichen:</span>{' '}
|
{headerButtons}
|
||||||
{client.last_alive ? new Date(client.last_alive).toLocaleString() : '-'}
|
<ButtonComponent cssClass="e-outline" onClick={handleManualRefresh} disabled={loading}>
|
||||||
|
{loading ? 'Aktualisiere...' : 'Aktualisieren'}
|
||||||
|
</ButtonComponent>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
{clients.length === 0 && (
|
{error && (
|
||||||
<div className="col-span-full text-center text-gray-400">Keine Clients gefunden.</div>
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<MessageComponent severity="Error">{error}</MessageComponent>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{holidayBannerEnabled && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
{(() => {
|
||||||
|
let severity: 'Info' | 'Warning' = 'Info';
|
||||||
|
let text = 'Ferienstatus wird geladen...';
|
||||||
|
let actionLabel: string | null = null;
|
||||||
|
|
||||||
|
if (!holidayLoading && holidayError) {
|
||||||
|
severity = 'Warning';
|
||||||
|
text = holidayError;
|
||||||
|
actionLabel = 'In Einstellungen prüfen';
|
||||||
|
} else if (!holidayLoading && !holidayError && !activePeriod) {
|
||||||
|
severity = 'Warning';
|
||||||
|
text = 'Keine aktive akademische Periode gesetzt.';
|
||||||
|
actionLabel = 'In Einstellungen setzen';
|
||||||
|
} else if (!holidayLoading && !holidayError && activePeriod && holidayOverlapCount === 0) {
|
||||||
|
severity = 'Warning';
|
||||||
|
text = `Aktive Periode: ${activePeriod.name}. Für diese Periode wurden noch keine Ferien importiert.`;
|
||||||
|
actionLabel = 'Ferien importieren';
|
||||||
|
} else if (!holidayLoading && !holidayError && activePeriod) {
|
||||||
|
severity = 'Info';
|
||||||
|
text = `Aktive Periode: ${activePeriod.name} (${formatIsoDateForDisplay(activePeriod.startDate)} bis ${formatIsoDateForDisplay(activePeriod.endDate)}), Ferienüberschneidungen: ${holidayOverlapCount}${
|
||||||
|
holidayFirst && holidayLast
|
||||||
|
? ` (von ${formatIsoDateForDisplay(holidayFirst)} bis ${formatIsoDateForDisplay(holidayLast)})`
|
||||||
|
: ''
|
||||||
|
}`;
|
||||||
|
actionLabel = 'In Einstellungen prüfen';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 8,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MessageComponent key={`${severity}:${text}`} severity={severity}>{text}</MessageComponent>
|
||||||
|
{isAdminOrHigher && actionLabel && !holidayLoading && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
paddingTop: 8,
|
||||||
|
borderTop: '1px solid #e2e8f0',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ButtonComponent cssClass="e-flat" onClick={openHolidaySettings}>
|
||||||
|
{actionLabel}
|
||||||
|
</ButtonComponent>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAdminOrHigher && hasUnassignedClients && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<MessageComponent severity="Warning">
|
||||||
|
Es gibt {unassignedClientCount} Client{unassignedClientCount === 1 ? '' : 's'} in der Gruppe
|
||||||
|
{' '}
|
||||||
|
{UNASSIGNED_GROUP_NAME}. Bitte den Client nach Registrierung einer Standard-Gruppe zuweisen.
|
||||||
|
</MessageComponent>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="e-card" style={{ borderTop: '4px solid #2563eb' }}>
|
||||||
|
<div className="e-card-content">
|
||||||
|
<div style={{ fontSize: 13, color: '#475569' }}>Clients gesamt</div>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 700 }}>{globalStats.total}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card" style={{ borderTop: '4px solid #16a34a' }}>
|
||||||
|
<div className="e-card-content">
|
||||||
|
<div style={{ fontSize: 13, color: '#475569' }}>Online</div>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 700 }}>{globalStats.online}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#166534' }}>{globalStats.onlineRatio}% verfügbar</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card" style={{ borderTop: '4px solid #f59e0b' }}>
|
||||||
|
<div className="e-card-content">
|
||||||
|
<div style={{ fontSize: 13, color: '#475569' }}>Warnungsgruppen</div>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 700 }}>{globalStats.warningGroups}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card" style={{ borderTop: '4px solid #dc2626' }}>
|
||||||
|
<div className="e-card-content">
|
||||||
|
<div style={{ fontSize: 13, color: '#475569' }}>Offline</div>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 700 }}>{globalStats.offline}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#991b1b' }}>{globalStats.activeIncidents} Gruppen betroffen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 20 }}>
|
||||||
|
<div className="e-card">
|
||||||
|
<div className="e-card-header">
|
||||||
|
<div className="e-card-header-caption">
|
||||||
|
<div className="e-card-header-title">Aktive Inhalte je Gruppe</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card-content">
|
||||||
|
<GridComponent
|
||||||
|
dataSource={contentRows}
|
||||||
|
allowPaging={true}
|
||||||
|
pageSettings={{ pageSize: 5 }}
|
||||||
|
allowSorting={true}
|
||||||
|
height={240}
|
||||||
|
>
|
||||||
|
<ColumnsDirective>
|
||||||
|
<ColumnDirective field="groupName" headerText="Gruppe" width="150" />
|
||||||
|
<ColumnDirective field="title" headerText="Inhalt" width="180" />
|
||||||
|
<ColumnDirective field="contentType" headerText="Typ" width="110" />
|
||||||
|
<ColumnDirective field="recurring" headerText="Wdh." width="70" textAlign="Center" />
|
||||||
|
</ColumnsDirective>
|
||||||
|
<Inject services={[Page, Sort]} />
|
||||||
|
</GridComponent>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="e-card"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="e-card-header">
|
||||||
|
<div className="e-card-header-caption" style={{ width: '100%', textAlign: 'left' }}>
|
||||||
|
<div className="e-card-header-title">Aktive Vorfälle</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card-content" style={{ maxHeight: 280, overflowY: 'auto', textAlign: 'left' }}>
|
||||||
|
{incidentGroups.length === 0 && (
|
||||||
|
<MessageComponent severity="Success">Keine aktiven Ausfälle in den gefilterten Gruppen.</MessageComponent>
|
||||||
|
)}
|
||||||
|
{incidentGroups.map(item => (
|
||||||
|
<div
|
||||||
|
key={item.group.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '10px 0',
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{item.group.name}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#64748b' }}>
|
||||||
|
{item.offlineClients.length} von {item.group.clients.length} Clients offline
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isAdminOrHigher && (
|
||||||
|
<ButtonComponent
|
||||||
|
cssClass="e-flat e-danger"
|
||||||
|
onClick={() => openRestartDialog(item.group.id)}
|
||||||
|
>
|
||||||
|
Offline neu starten
|
||||||
|
</ButtonComponent>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="e-card" style={{ marginBottom: 16 }}>
|
||||||
|
<div className="e-card-header">
|
||||||
|
<div className="e-card-header-caption" style={{ width: '100%' }}>
|
||||||
|
<div className="e-card-header-title">Gruppenstatus</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card-content">
|
||||||
|
<GridComponent
|
||||||
|
dataSource={groupRows}
|
||||||
|
allowPaging={true}
|
||||||
|
pageSettings={{ pageSize: 8 }}
|
||||||
|
allowSorting={true}
|
||||||
|
toolbar={['Search']}
|
||||||
|
height={320}
|
||||||
|
rowSelected={args => {
|
||||||
|
const data = args.data as GroupRow;
|
||||||
|
setSelectedGroupId(data.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ColumnsDirective>
|
||||||
|
<ColumnDirective field="name" headerText="Gruppe" width="180" />
|
||||||
|
<ColumnDirective field="totalClients" headerText="Clients" width="90" textAlign="Right" />
|
||||||
|
<ColumnDirective field="onlineClients" headerText="Online" width="90" textAlign="Right" />
|
||||||
|
<ColumnDirective field="offlineClients" headerText="Offline" width="90" textAlign="Right" />
|
||||||
|
<ColumnDirective
|
||||||
|
field="healthLabel"
|
||||||
|
headerText="Status"
|
||||||
|
width="130"
|
||||||
|
template={(props: GroupRow) => healthBadge(props.onlineClients, props.totalClients)}
|
||||||
|
/>
|
||||||
|
{isAdminOrHigher && (
|
||||||
|
<ColumnDirective
|
||||||
|
headerText="Aktion"
|
||||||
|
width="170"
|
||||||
|
template={(props: GroupRow) => (
|
||||||
|
<ButtonComponent
|
||||||
|
cssClass="e-flat e-danger"
|
||||||
|
onClick={() => openRestartDialog(props.id)}
|
||||||
|
disabled={props.offlineClients === 0}
|
||||||
|
>
|
||||||
|
Offline neu starten
|
||||||
|
</ButtonComponent>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ColumnsDirective>
|
||||||
|
<Inject services={[Page, Search, Sort, Toolbar]} />
|
||||||
|
</GridComponent>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="e-card">
|
||||||
|
<div className="e-card-header">
|
||||||
|
<div className="e-card-header-caption" style={{ width: '100%' }}>
|
||||||
|
<div className="e-card-header-title">Client-Status</div>
|
||||||
|
<div style={{ marginTop: 8, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<ButtonComponent
|
||||||
|
cssClass={!selectedGroupId ? 'e-primary' : 'e-flat'}
|
||||||
|
onClick={() => setSelectedGroupId(null)}
|
||||||
|
>
|
||||||
|
Alle Gruppen
|
||||||
|
</ButtonComponent>
|
||||||
|
{groupRows.map(group => (
|
||||||
|
<ButtonComponent
|
||||||
|
key={group.id}
|
||||||
|
cssClass={selectedGroupId === group.id ? 'e-primary' : 'e-flat'}
|
||||||
|
onClick={() => setSelectedGroupId(group.id)}
|
||||||
|
>
|
||||||
|
{group.name}
|
||||||
|
</ButtonComponent>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card-content">
|
||||||
|
<GridComponent
|
||||||
|
dataSource={clientRows}
|
||||||
|
allowPaging={true}
|
||||||
|
pageSettings={{ pageSize: 10 }}
|
||||||
|
allowSorting={true}
|
||||||
|
toolbar={['Search']}
|
||||||
|
height={420}
|
||||||
|
>
|
||||||
|
<ColumnsDirective>
|
||||||
|
<ColumnDirective field="description" headerText="Client" width="180" />
|
||||||
|
<ColumnDirective field="groupName" headerText="Gruppe" width="130" />
|
||||||
|
<ColumnDirective field="hostname" headerText="Hostname" width="140" />
|
||||||
|
<ColumnDirective field="ip" headerText="IP" width="140" />
|
||||||
|
<ColumnDirective
|
||||||
|
field="aliveLabel"
|
||||||
|
headerText="Status"
|
||||||
|
width="110"
|
||||||
|
template={(props: ClientRow) => aliveBadge(props.isAlive)}
|
||||||
|
/>
|
||||||
|
<ColumnDirective field="lastAlive" headerText="Letztes Lebenszeichen" width="160" />
|
||||||
|
</ColumnsDirective>
|
||||||
|
<Inject services={[Page, Search, Sort, Toolbar]} />
|
||||||
|
</GridComponent>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogComponent
|
||||||
|
visible={Boolean(restartDialogGroup)}
|
||||||
|
isModal={true}
|
||||||
|
showCloseIcon={true}
|
||||||
|
width="520px"
|
||||||
|
header={restartDialogGroup ? `Offline-Clients neu starten: ${restartDialogGroup.name}` : 'Offline-Clients neu starten'}
|
||||||
|
close={() => {
|
||||||
|
if (!restartBusy) setRestartDialogGroup(null);
|
||||||
|
}}
|
||||||
|
footerTemplate={() => (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<ButtonComponent cssClass="e-flat" disabled={restartBusy} onClick={() => setRestartDialogGroup(null)}>
|
||||||
|
Abbrechen
|
||||||
|
</ButtonComponent>
|
||||||
|
<ButtonComponent cssClass="e-primary" disabled={restartBusy} onClick={() => void handleRestartGroupOffline()}>
|
||||||
|
{restartBusy ? 'Starte neu...' : 'Neu starten'}
|
||||||
|
</ButtonComponent>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
{restartDialogGroup && (
|
||||||
|
<>
|
||||||
|
<p style={{ marginTop: 0 }}>
|
||||||
|
Es werden alle aktuell offline gemeldeten Clients dieser Gruppe neu gestartet.
|
||||||
|
</p>
|
||||||
|
<div style={{ fontSize: 13, color: '#475569', marginBottom: 8 }}>
|
||||||
|
Betroffene Clients:{' '}
|
||||||
|
{restartDialogGroup.clients.filter((client: Client) => !client.is_alive).length}
|
||||||
|
</div>
|
||||||
|
<div style={{ maxHeight: 180, overflowY: 'auto', border: '1px solid #e2e8f0', borderRadius: 8, padding: 8 }}>
|
||||||
|
{restartDialogGroup.clients
|
||||||
|
.filter((client: Client) => !client.is_alive)
|
||||||
|
.map((client: Client) => (
|
||||||
|
<div
|
||||||
|
key={client.uuid}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '6px 4px',
|
||||||
|
borderBottom: '1px solid #f1f5f9',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{client.description || client.uuid}</span>
|
||||||
|
<span style={{ color: '#64748b' }}>{client.ip || '-'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogComponent>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
15
dashboard/src/dateFormatting.ts
Normal file
15
dashboard/src/dateFormatting.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export function formatIsoDateForDisplay(isoDate: string | null | undefined): string {
|
||||||
|
if (!isoDate) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new Date(`${isoDate}T00:00:00`);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
return isoDate;
|
||||||
|
}
|
||||||
|
return parsed.toLocaleDateString('de-DE');
|
||||||
|
} catch {
|
||||||
|
return isoDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
const Einstellungen: React.FC = () => (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-bold mb-4">Einstellungen</h2>
|
|
||||||
<p>Willkommen im Infoscreen-Management Einstellungen.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
export default Einstellungen;
|
|
||||||
36
dashboard/src/hooks/useClientDelete.ts
Normal file
36
dashboard/src/hooks/useClientDelete.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { deleteClient } from '../apiClients';
|
||||||
|
|
||||||
|
export function useClientDelete(onDeleted?: (uuid: string) => void) {
|
||||||
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
const [deleteClientId, setDeleteClientId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Details-Modal separat im Parent verwalten!
|
||||||
|
|
||||||
|
const handleDelete = (uuid: string) => {
|
||||||
|
setDeleteClientId(uuid);
|
||||||
|
setShowDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (deleteClientId) {
|
||||||
|
await deleteClient(deleteClientId);
|
||||||
|
setShowDialog(false);
|
||||||
|
if (onDeleted) onDeleted(deleteClientId);
|
||||||
|
setDeleteClientId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelDelete = () => {
|
||||||
|
setShowDialog(false);
|
||||||
|
setDeleteClientId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
showDialog,
|
||||||
|
deleteClientId,
|
||||||
|
handleDelete,
|
||||||
|
confirmDelete,
|
||||||
|
cancelDelete,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
@tailwind base;
|
/* Tailwind removed: base/components/utilities directives no longer used. */
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
/* Custom overrides moved to theme-overrides.css to load after Syncfusion styles */
|
||||||
|
|
||||||
/* :root {
|
/* :root {
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { fetchGroups, createGroup, deleteGroup, renameGroup } from './apiGroups'
|
|||||||
import type { Client } from './apiClients';
|
import type { Client } from './apiClients';
|
||||||
import type { KanbanComponent as KanbanComponentType } from '@syncfusion/ej2-react-kanban';
|
import type { KanbanComponent as KanbanComponentType } from '@syncfusion/ej2-react-kanban';
|
||||||
import { DialogComponent } from '@syncfusion/ej2-react-popups';
|
import { DialogComponent } from '@syncfusion/ej2-react-popups';
|
||||||
|
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
|
||||||
|
import { TextBoxComponent } from '@syncfusion/ej2-react-inputs';
|
||||||
|
import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns';
|
||||||
|
import type { ChangedEventArgs as TextBoxChangedArgs } from '@syncfusion/ej2-react-inputs';
|
||||||
|
import type { ChangeEventArgs as DropDownChangeArgs } from '@syncfusion/ej2-react-dropdowns';
|
||||||
import { useToast } from './components/ToastProvider';
|
import { useToast } from './components/ToastProvider';
|
||||||
import { L10n } from '@syncfusion/ej2-base';
|
import { L10n } from '@syncfusion/ej2-base';
|
||||||
|
|
||||||
@@ -41,10 +46,10 @@ const de = {
|
|||||||
rename: 'Umbenennen',
|
rename: 'Umbenennen',
|
||||||
confirmDelete: 'Löschbestätigung',
|
confirmDelete: 'Löschbestätigung',
|
||||||
reallyDelete: (name: string) => `Möchten Sie die Gruppe <b>${name}</b> wirklich löschen?`,
|
reallyDelete: (name: string) => `Möchten Sie die Gruppe <b>${name}</b> wirklich löschen?`,
|
||||||
clientsMoved: 'Alle Clients werden in "Nicht zugeordnet" verschoben.',
|
clientsMoved: 'Alle Clients werden nach "Nicht zugeordnet" verschoben.',
|
||||||
groupCreated: 'Gruppe angelegt',
|
groupCreated: 'Gruppe angelegt',
|
||||||
groupDeleted: 'Gruppe gelöscht. Clients in "Nicht zugeordnet" verschoben',
|
groupDeleted: 'Gruppe gelöscht. Alle Clients wurden nach "Nicht zugeordnet" verschoben.',
|
||||||
groupRenamed: 'Gruppenname geändert',
|
groupRenamed: 'Gruppe umbenannt',
|
||||||
selectGroup: 'Gruppe wählen',
|
selectGroup: 'Gruppe wählen',
|
||||||
newName: 'Neuer Name',
|
newName: 'Neuer Name',
|
||||||
warning: 'Achtung:',
|
warning: 'Achtung:',
|
||||||
@@ -72,7 +77,7 @@ L10n.load({
|
|||||||
const Infoscreen_groups: React.FC = () => {
|
const Infoscreen_groups: React.FC = () => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [clients, setClients] = useState<KanbanClient[]>([]);
|
const [clients, setClients] = useState<KanbanClient[]>([]);
|
||||||
const [groups, setGroups] = useState<{ keyField: string; headerText: string }[]>([]);
|
const [groups, setGroups] = useState<{ keyField: string; headerText: string; id?: number }[]>([]);
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
const [newGroupName, setNewGroupName] = useState('');
|
const [newGroupName, setNewGroupName] = useState('');
|
||||||
const [draggedCard, setDraggedCard] = useState<{ id: string; fromColumn: string } | null>(null);
|
const [draggedCard, setDraggedCard] = useState<{ id: string; fromColumn: string } | null>(null);
|
||||||
@@ -106,8 +111,13 @@ const Infoscreen_groups: React.FC = () => {
|
|||||||
data.map((c, i) => ({
|
data.map((c, i) => ({
|
||||||
...c,
|
...c,
|
||||||
Id: c.uuid,
|
Id: c.uuid,
|
||||||
Status: groupMap[c.group_id] || 'Nicht zugeordnet',
|
Status:
|
||||||
Summary: c.location || `Client ${i + 1}`,
|
c.group_id === 1
|
||||||
|
? 'Nicht zugeordnet'
|
||||||
|
: typeof c.group_id === 'number' && groupMap[c.group_id]
|
||||||
|
? groupMap[c.group_id]
|
||||||
|
: 'Nicht zugeordnet',
|
||||||
|
Summary: c.description || `Client ${i + 1}`,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -125,9 +135,31 @@ const Infoscreen_groups: React.FC = () => {
|
|||||||
timeOut: 5000,
|
timeOut: 5000,
|
||||||
showCloseButton: false,
|
showCloseButton: false,
|
||||||
});
|
});
|
||||||
setGroups([...groups, { keyField: newGroup.name, headerText: newGroup.name }]);
|
setGroups([
|
||||||
|
...groups,
|
||||||
|
{ keyField: newGroup.name, headerText: newGroup.name, id: newGroup.id },
|
||||||
|
]);
|
||||||
setNewGroupName('');
|
setNewGroupName('');
|
||||||
setShowDialog(false);
|
setShowDialog(false);
|
||||||
|
|
||||||
|
// Update group order to include the new group
|
||||||
|
try {
|
||||||
|
const orderResponse = await fetch('/api/groups/order');
|
||||||
|
if (orderResponse.ok) {
|
||||||
|
const orderData = await orderResponse.json();
|
||||||
|
const currentOrder = orderData.order || [];
|
||||||
|
// Add new group ID to the end if not already present
|
||||||
|
if (!currentOrder.includes(newGroup.id)) {
|
||||||
|
await fetch('/api/groups/order', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ order: [...currentOrder, newGroup.id] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update group order:', err);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.show({
|
toast.show({
|
||||||
content: (err as Error).message,
|
content: (err as Error).message,
|
||||||
@@ -141,12 +173,19 @@ const Infoscreen_groups: React.FC = () => {
|
|||||||
// Löschen einer Gruppe
|
// Löschen einer Gruppe
|
||||||
const handleDeleteGroup = async (groupName: string) => {
|
const handleDeleteGroup = async (groupName: string) => {
|
||||||
try {
|
try {
|
||||||
|
// Find the group ID before deleting
|
||||||
|
const groupToDelete = groups.find(g => g.headerText === groupName);
|
||||||
|
const deletedGroupId = groupToDelete?.id;
|
||||||
|
|
||||||
// Clients der Gruppe in "Nicht zugeordnet" verschieben
|
// Clients der Gruppe in "Nicht zugeordnet" verschieben
|
||||||
const groupClients = clients.filter(c => c.Status === groupName);
|
const groupClients = clients.filter(c => c.Status === groupName);
|
||||||
if (groupClients.length > 0) {
|
if (groupClients.length > 0) {
|
||||||
|
// Ermittle die ID der Zielgruppe "Nicht zugeordnet"
|
||||||
|
const target = groups.find(g => g.headerText === 'Nicht zugeordnet');
|
||||||
|
if (!target || !target.id) throw new Error('Zielgruppe "Nicht zugeordnet" nicht gefunden');
|
||||||
await updateClientGroup(
|
await updateClientGroup(
|
||||||
groupClients.map(c => c.Id),
|
groupClients.map(c => c.Id),
|
||||||
'Nicht zugeordnet'
|
target.id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await deleteGroup(groupName);
|
await deleteGroup(groupName);
|
||||||
@@ -156,6 +195,27 @@ const Infoscreen_groups: React.FC = () => {
|
|||||||
timeOut: 5000,
|
timeOut: 5000,
|
||||||
showCloseButton: false,
|
showCloseButton: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update group order to remove the deleted group
|
||||||
|
if (deletedGroupId) {
|
||||||
|
try {
|
||||||
|
const orderResponse = await fetch('/api/groups/order');
|
||||||
|
if (orderResponse.ok) {
|
||||||
|
const orderData = await orderResponse.json();
|
||||||
|
const currentOrder = orderData.order || [];
|
||||||
|
// Remove deleted group ID from order
|
||||||
|
const updatedOrder = currentOrder.filter((id: number) => id !== deletedGroupId);
|
||||||
|
await fetch('/api/groups/order', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ order: updatedOrder }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update group order:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Gruppen und Clients neu laden
|
// Gruppen und Clients neu laden
|
||||||
const groupData = await fetchGroups();
|
const groupData = await fetchGroups();
|
||||||
const groupMap = Object.fromEntries(groupData.map((g: Group) => [g.id, g.name]));
|
const groupMap = Object.fromEntries(groupData.map((g: Group) => [g.id, g.name]));
|
||||||
@@ -165,8 +225,11 @@ const Infoscreen_groups: React.FC = () => {
|
|||||||
data.map((c, i) => ({
|
data.map((c, i) => ({
|
||||||
...c,
|
...c,
|
||||||
Id: c.uuid,
|
Id: c.uuid,
|
||||||
Status: groupMap[c.group_id] || 'Nicht zugeordnet',
|
Status:
|
||||||
Summary: c.location || `Client ${i + 1}`,
|
typeof c.group_id === 'number' && groupMap[c.group_id]
|
||||||
|
? groupMap[c.group_id]
|
||||||
|
: 'Nicht zugeordnet',
|
||||||
|
Summary: c.description || `Client ${i + 1}`,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -199,8 +262,11 @@ const Infoscreen_groups: React.FC = () => {
|
|||||||
data.map((c, i) => ({
|
data.map((c, i) => ({
|
||||||
...c,
|
...c,
|
||||||
Id: c.uuid,
|
Id: c.uuid,
|
||||||
Status: groupMap[c.group_id] || 'Nicht zugeordnet',
|
Status:
|
||||||
Summary: c.location || `Client ${i + 1}`,
|
typeof c.group_id === 'number' && groupMap[c.group_id]
|
||||||
|
? groupMap[c.group_id]
|
||||||
|
: 'Nicht zugeordnet',
|
||||||
|
Summary: c.description || `Client ${i + 1}`,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -260,7 +326,10 @@ const Infoscreen_groups: React.FC = () => {
|
|||||||
const clientIds = dropped.map((card: KanbanClient) => card.Id);
|
const clientIds = dropped.map((card: KanbanClient) => card.Id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateClientGroup(clientIds, targetGroupName);
|
// Ermittle Zielgruppen-ID anhand des Namens
|
||||||
|
const target = groups.find(g => g.headerText === targetGroupName);
|
||||||
|
if (!target || !target.id) throw new Error('Zielgruppe nicht gefunden');
|
||||||
|
await updateClientGroup(clientIds, target.id);
|
||||||
fetchGroups().then((groupData: Group[]) => {
|
fetchGroups().then((groupData: Group[]) => {
|
||||||
const groupMap = Object.fromEntries(groupData.map(g => [g.id, g.name]));
|
const groupMap = Object.fromEntries(groupData.map(g => [g.id, g.name]));
|
||||||
setGroups(
|
setGroups(
|
||||||
@@ -275,8 +344,11 @@ const Infoscreen_groups: React.FC = () => {
|
|||||||
data.map((c, i) => ({
|
data.map((c, i) => ({
|
||||||
...c,
|
...c,
|
||||||
Id: c.uuid,
|
Id: c.uuid,
|
||||||
Status: groupMap[c.group_id] || 'Nicht zugeordnet',
|
Status:
|
||||||
Summary: c.location || `Client ${i + 1}`,
|
typeof c.group_id === 'number' && groupMap[c.group_id]
|
||||||
|
? groupMap[c.group_id]
|
||||||
|
: 'Nicht zugeordnet',
|
||||||
|
Summary: c.description || `Client ${i + 1}`,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
// Nach dem Laden: Karten deselektieren
|
// Nach dem Laden: Karten deselektieren
|
||||||
@@ -289,7 +361,12 @@ const Infoscreen_groups: React.FC = () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
alert('Fehler beim Aktualisieren der Clients');
|
toast.show({
|
||||||
|
content: 'Fehler beim Aktualisieren der Clients',
|
||||||
|
cssClass: 'e-toast-danger',
|
||||||
|
timeOut: 0,
|
||||||
|
showCloseButton: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setDraggedCard(null);
|
setDraggedCard(null);
|
||||||
};
|
};
|
||||||
@@ -302,26 +379,24 @@ const Infoscreen_groups: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="dialog-target">
|
<div id="dialog-target">
|
||||||
<h2 className="text-xl font-bold mb-4">{de.title}</h2>
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, marginBottom: 16 }}>{de.title}</h2>
|
||||||
<div className="flex gap-2 mb-4">
|
<div
|
||||||
<button
|
style={{
|
||||||
className="px-4 py-2 bg-blue-500 text-white rounded"
|
display: 'flex',
|
||||||
onClick={() => setShowDialog(true)}
|
flexWrap: 'wrap',
|
||||||
|
gap: '12px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
<ButtonComponent cssClass="e-primary" onClick={() => setShowDialog(true)}>
|
||||||
{de.newGroup}
|
{de.newGroup}
|
||||||
</button>
|
</ButtonComponent>
|
||||||
<button
|
<ButtonComponent cssClass="e-warning" onClick={() => setRenameDialog({ open: true, oldName: '', newName: '' })}>
|
||||||
className="px-4 py-2 bg-yellow-500 text-white rounded"
|
|
||||||
onClick={() => setRenameDialog({ open: true, oldName: '', newName: '' })}
|
|
||||||
>
|
|
||||||
{de.renameGroup}
|
{de.renameGroup}
|
||||||
</button>
|
</ButtonComponent>
|
||||||
<button
|
<ButtonComponent cssClass="e-danger" onClick={() => setDeleteDialog({ open: true, groupName: '' })}>
|
||||||
className="px-4 py-2 bg-red-500 text-white rounded"
|
|
||||||
onClick={() => setDeleteDialog({ open: true, groupName: '' })}
|
|
||||||
>
|
|
||||||
{de.deleteGroup}
|
{de.deleteGroup}
|
||||||
</button>
|
</ButtonComponent>
|
||||||
</div>
|
</div>
|
||||||
<KanbanComponent
|
<KanbanComponent
|
||||||
locale="de"
|
locale="de"
|
||||||
@@ -339,144 +414,146 @@ const Infoscreen_groups: React.FC = () => {
|
|||||||
columns={kanbanColumns}
|
columns={kanbanColumns}
|
||||||
/>
|
/>
|
||||||
{showDialog && (
|
{showDialog && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center">
|
<DialogComponent
|
||||||
<div className="bg-white p-6 rounded shadow">
|
visible={showDialog}
|
||||||
<h3 className="mb-2 font-bold">{de.newGroup}</h3>
|
header={de.newGroup}
|
||||||
<input
|
close={() => setShowDialog(false)}
|
||||||
className="border p-2 mb-2 w-full"
|
target="#dialog-target"
|
||||||
value={newGroupName}
|
width="420px"
|
||||||
onChange={e => setNewGroupName(e.target.value)}
|
footerTemplate={() => (
|
||||||
placeholder="Raumname"
|
<div className="flex gap-2 justify-end">
|
||||||
/>
|
<ButtonComponent cssClass="e-primary" onClick={handleAddGroup} disabled={!newGroupName.trim()}>
|
||||||
<div className="flex gap-2">
|
|
||||||
<button className="bg-blue-500 text-white px-4 py-2 rounded" onClick={handleAddGroup}>
|
|
||||||
{de.add}
|
{de.add}
|
||||||
</button>
|
</ButtonComponent>
|
||||||
<button
|
<ButtonComponent onClick={() => setShowDialog(false)}>{de.cancel}</ButtonComponent>
|
||||||
className="bg-gray-300 px-4 py-2 rounded"
|
|
||||||
onClick={() => setShowDialog(false)}
|
|
||||||
>
|
|
||||||
{de.cancel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{renameDialog.open && (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center">
|
|
||||||
<div className="bg-white p-6 rounded shadow">
|
|
||||||
<h3 className="mb-2 font-bold">{de.renameGroup}</h3>
|
|
||||||
<select
|
|
||||||
className="border p-2 mb-2 w-full"
|
|
||||||
value={renameDialog.oldName}
|
|
||||||
onChange={e =>
|
|
||||||
setRenameDialog({
|
|
||||||
...renameDialog,
|
|
||||||
oldName: e.target.value,
|
|
||||||
newName: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<option value="">{de.selectGroup}</option>
|
<div className="mt-2">
|
||||||
{groups
|
<TextBoxComponent
|
||||||
.filter(g => g.headerText !== 'Nicht zugeordnet')
|
value={newGroupName}
|
||||||
.map(g => (
|
placeholder="Raumname"
|
||||||
<option key={g.keyField} value={g.headerText}>
|
floatLabelType="Auto"
|
||||||
{g.headerText}
|
change={(args: TextBoxChangedArgs) => setNewGroupName(String(args.value ?? ''))}
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
className="border p-2 mb-2 w-full"
|
|
||||||
value={renameDialog.newName}
|
|
||||||
onChange={e => setRenameDialog({ ...renameDialog, newName: e.target.value })}
|
|
||||||
placeholder={de.newName}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
</div>
|
||||||
<button
|
</DialogComponent>
|
||||||
className="bg-blue-500 text-white px-4 py-2 rounded"
|
)}
|
||||||
|
{renameDialog.open && (
|
||||||
|
<DialogComponent
|
||||||
|
visible={renameDialog.open}
|
||||||
|
header={de.renameGroup}
|
||||||
|
showCloseIcon={true}
|
||||||
|
close={() => setRenameDialog({ open: false, oldName: '', newName: '' })}
|
||||||
|
target="#dialog-target"
|
||||||
|
width="480px"
|
||||||
|
footerTemplate={() => (
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<ButtonComponent
|
||||||
|
cssClass="e-primary"
|
||||||
onClick={handleRenameGroup}
|
onClick={handleRenameGroup}
|
||||||
disabled={!renameDialog.oldName || !renameDialog.newName}
|
disabled={!renameDialog.oldName || !renameDialog.newName}
|
||||||
>
|
>
|
||||||
{de.rename}
|
{de.rename}
|
||||||
</button>
|
</ButtonComponent>
|
||||||
<button
|
<ButtonComponent onClick={() => setRenameDialog({ open: false, oldName: '', newName: '' })}>
|
||||||
className="bg-gray-300 px-4 py-2 rounded"
|
|
||||||
onClick={() => setRenameDialog({ open: false, oldName: '', newName: '' })}
|
|
||||||
>
|
|
||||||
{de.cancel}
|
{de.cancel}
|
||||||
</button>
|
</ButtonComponent>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3 mt-2">
|
||||||
|
<DropDownListComponent
|
||||||
|
placeholder={de.selectGroup}
|
||||||
|
dataSource={groups.filter(g => g.headerText !== 'Nicht zugeordnet').map(g => g.headerText)}
|
||||||
|
value={renameDialog.oldName}
|
||||||
|
change={(e: DropDownChangeArgs) =>
|
||||||
|
setRenameDialog({
|
||||||
|
...renameDialog,
|
||||||
|
oldName: String(e.value ?? ''),
|
||||||
|
newName: String(e.value ?? ''),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextBoxComponent
|
||||||
|
placeholder={de.newName}
|
||||||
|
value={renameDialog.newName}
|
||||||
|
floatLabelType="Auto"
|
||||||
|
change={(args: TextBoxChangedArgs) =>
|
||||||
|
setRenameDialog({ ...renameDialog, newName: String(args.value ?? '') })
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</DialogComponent>
|
||||||
)}
|
)}
|
||||||
{deleteDialog.open && (
|
{deleteDialog.open && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center">
|
<DialogComponent
|
||||||
<div className="bg-white p-6 rounded shadow">
|
visible={deleteDialog.open}
|
||||||
<h3 className="mb-2 font-bold">{de.deleteGroup}</h3>
|
header={de.deleteGroup}
|
||||||
<select
|
showCloseIcon={true}
|
||||||
className="border p-2 mb-2 w-full"
|
close={() => setDeleteDialog({ open: false, groupName: '' })}
|
||||||
value={deleteDialog.groupName}
|
target="#dialog-target"
|
||||||
onChange={e => setDeleteDialog({ ...deleteDialog, groupName: e.target.value })}
|
width="520px"
|
||||||
>
|
footerTemplate={() => (
|
||||||
<option value="">{de.selectGroup}</option>
|
<div className="flex gap-2 justify-end">
|
||||||
{groups
|
<ButtonComponent
|
||||||
.filter(g => g.headerText !== 'Nicht zugeordnet')
|
cssClass="e-danger"
|
||||||
.map(g => (
|
|
||||||
<option key={g.keyField} value={g.headerText}>
|
|
||||||
{g.headerText}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<p>{de.clientsMoved}</p>
|
|
||||||
{deleteDialog.groupName && (
|
|
||||||
<div className="bg-yellow-100 text-yellow-800 p-2 rounded mb-2 text-sm">
|
|
||||||
<strong>{de.warning}</strong> Möchten Sie die Gruppe <b>{deleteDialog.groupName}</b>{' '}
|
|
||||||
wirklich löschen?
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2 mt-2">
|
|
||||||
<button
|
|
||||||
className="bg-red-500 text-white px-4 py-2 rounded"
|
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
disabled={!deleteDialog.groupName}
|
disabled={!deleteDialog.groupName}
|
||||||
>
|
>
|
||||||
{de.deleteGroup}
|
{de.deleteGroup}
|
||||||
</button>
|
</ButtonComponent>
|
||||||
<button
|
<ButtonComponent onClick={() => setDeleteDialog({ open: false, groupName: '' })}>
|
||||||
className="bg-gray-300 px-4 py-2 rounded"
|
|
||||||
onClick={() => setDeleteDialog({ open: false, groupName: '' })}
|
|
||||||
>
|
|
||||||
{de.cancel}
|
{de.cancel}
|
||||||
</button>
|
</ButtonComponent>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3 mt-2">
|
||||||
|
<DropDownListComponent
|
||||||
|
placeholder={de.selectGroup}
|
||||||
|
dataSource={groups.filter(g => g.headerText !== 'Nicht zugeordnet').map(g => g.headerText)}
|
||||||
|
value={deleteDialog.groupName}
|
||||||
|
change={(e: DropDownChangeArgs) =>
|
||||||
|
setDeleteDialog({ ...deleteDialog, groupName: String(e.value ?? '') })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-gray-600">{de.clientsMoved}</p>
|
||||||
|
{deleteDialog.groupName && (
|
||||||
|
<div className="bg-yellow-100 text-yellow-800 p-2 rounded text-sm">
|
||||||
|
<strong>{de.warning}</strong> Möchten Sie die Gruppe <b>{deleteDialog.groupName}</b> wirklich löschen?
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogComponent>
|
||||||
|
)}
|
||||||
{showDeleteConfirm && deleteDialog.groupName && (
|
{showDeleteConfirm && deleteDialog.groupName && (
|
||||||
<DialogComponent
|
<DialogComponent
|
||||||
width="350px"
|
width="380px"
|
||||||
header={de.confirmDelete}
|
header={de.confirmDelete}
|
||||||
visible={showDeleteConfirm}
|
visible={showDeleteConfirm}
|
||||||
|
showCloseIcon={true}
|
||||||
close={() => setShowDeleteConfirm(false)}
|
close={() => setShowDeleteConfirm(false)}
|
||||||
|
target="#dialog-target"
|
||||||
footerTemplate={() => (
|
footerTemplate={() => (
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
<button
|
<ButtonComponent
|
||||||
className="bg-red-500 text-white px-4 py-2 rounded"
|
cssClass="e-danger"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleDeleteGroup(deleteDialog.groupName);
|
handleDeleteGroup(deleteDialog.groupName!);
|
||||||
setShowDeleteConfirm(false);
|
setShowDeleteConfirm(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{de.yesDelete}
|
{de.yesDelete}
|
||||||
</button>
|
</ButtonComponent>
|
||||||
<button
|
<ButtonComponent
|
||||||
className="bg-gray-300 px-4 py-2 rounded"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowDeleteConfirm(false);
|
setShowDeleteConfirm(false);
|
||||||
setDeleteDialog({ open: false, groupName: '' });
|
setDeleteDialog({ open: false, groupName: '' });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{de.cancel}
|
{de.cancel}
|
||||||
</button>
|
</ButtonComponent>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -488,8 +565,6 @@ const Infoscreen_groups: React.FC = () => {
|
|||||||
</DialogComponent>
|
</DialogComponent>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
98
dashboard/src/login.tsx
Normal file
98
dashboard/src/login.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from './useAuth';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { login, loading, error, logout } = useAuth();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const isDev = import.meta.env.MODE !== 'production';
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
setMessage('Login erfolgreich');
|
||||||
|
// Redirect to dashboard after successful login
|
||||||
|
navigate('/');
|
||||||
|
} catch (err) {
|
||||||
|
setMessage(err instanceof Error ? err.message : 'Login fehlgeschlagen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||||
|
<form onSubmit={handleSubmit} style={{ width: 360, padding: 24, border: '1px solid #ddd', borderRadius: 8, background: '#fff' }}>
|
||||||
|
<h2 style={{ marginTop: 0 }}>Anmeldung</h2>
|
||||||
|
{message && <div style={{ color: message.includes('erfolgreich') ? 'green' : 'crimson', marginBottom: 12 }}>{message}</div>}
|
||||||
|
{error && <div style={{ color: 'crimson', marginBottom: 12 }}>{error}</div>}
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: 4 }}>Benutzername</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ width: '100%', padding: 8 }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: 4 }}>Passwort</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ width: '100%', padding: 8 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={loading} style={{ width: '100%', padding: 10 }}>
|
||||||
|
{loading ? 'Anmelden ...' : 'Anmelden'}
|
||||||
|
</button>
|
||||||
|
{isDev && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/dev-login-superadmin', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || data.error) throw new Error(data.error || 'Dev-Login fehlgeschlagen');
|
||||||
|
setMessage('Dev-Login erfolgreich (Superadmin)');
|
||||||
|
// Refresh the page/state; the RequireAuth will render the app
|
||||||
|
window.location.href = '/';
|
||||||
|
} catch (err) {
|
||||||
|
setMessage(err instanceof Error ? err.message : 'Dev-Login fehlgeschlagen');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ width: '100%', padding: 10, marginTop: 10 }}
|
||||||
|
>
|
||||||
|
Dev-Login (Superadmin)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await logout();
|
||||||
|
setMessage('Abgemeldet.');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ width: '100%', padding: 10, marginTop: 10, background: '#f5f5f5' }}
|
||||||
|
>
|
||||||
|
Abmelden & zurück zur Anmeldung
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
dashboard/src/logout.tsx
Normal file
41
dashboard/src/logout.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from './useAuth';
|
||||||
|
|
||||||
|
const Logout: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { logout } = useAuth();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await logout();
|
||||||
|
} catch (err) {
|
||||||
|
if (mounted) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Logout fehlgeschlagen';
|
||||||
|
setError(msg);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Weiter zur Login-Seite, auch wenn Logout-Request fehlschlägt
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [logout, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Abmeldung</h2>
|
||||||
|
<p>{error ? `Hinweis: ${error}` : 'Sie werden abgemeldet …'}</p>
|
||||||
|
<p style={{ marginTop: 16 }}>Falls nichts passiert: <a href="/login">Zur Login-Seite</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Logout;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user