Initial commit - copied workspace after database cleanup
This commit is contained in:
36
.env.example
Normal file
36
.env.example
Normal file
@@ -0,0 +1,36 @@
|
||||
# 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
|
||||
|
||||
# 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
|
||||
# MQTT_USER=your_mqtt_user
|
||||
# MQTT_PASSWORD=your_mqtt_password
|
||||
MQTT_KEEPALIVE=60
|
||||
|
||||
# Dashboard
|
||||
# Used when building the production dashboard image
|
||||
# VITE_API_URL=https://your.api.example.com/api
|
||||
|
||||
# Groups alive windows (seconds)
|
||||
HEARTBEAT_GRACE_PERIOD_DEV=15
|
||||
HEARTBEAT_GRACE_PERIOD_PROD=180
|
||||
|
||||
# Scheduler
|
||||
# Optional: force periodic republish even without changes
|
||||
# REFRESH_SECONDS=0
|
||||
|
||||
# Default admin bootstrap (server/init_defaults.py)
|
||||
DEFAULT_ADMIN_USERNAME=infoscreen_admin
|
||||
DEFAULT_ADMIN_PASSWORD=Info_screen_admin25!
|
||||
116
.github/copilot-instructions.md
vendored
Normal file
116
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
# Copilot instructions for infoscreen_2025
|
||||
|
||||
Use this as your shared context when proposing changes. Keep edits minimal and match existing patterns referenced below.
|
||||
|
||||
## Big picture
|
||||
- Multi-service app orchestrated by Docker Compose.
|
||||
- API: Flask + SQLAlchemy (MariaDB), in `server/` exposed on :8000 (health: `/health`).
|
||||
- Dashboard: React + Vite in `dashboard/`, dev on :5173, served via Nginx in prod.
|
||||
- MQTT broker: Eclipse Mosquitto, config in `mosquitto/config/mosquitto.conf`.
|
||||
- Listener: MQTT consumer handling discovery + heartbeats in `listener/listener.py`.
|
||||
- Scheduler: Publishes active events (per group) to MQTT retained topics in `scheduler/scheduler.py`.
|
||||
- Nginx: Reverse proxy routes `/api/*` and `/screenshots/*` to API; everything else to dashboard (`nginx.conf`).
|
||||
|
||||
## Service boundaries & data flow
|
||||
- Database connection string is passed as `DB_CONN` (mysql+pymysql) to Python services.
|
||||
- API builds its engine in `server/database.py` (loads `.env` only in development).
|
||||
- Scheduler loads `DB_CONN` in `scheduler/db_utils.py`.
|
||||
- Listener also creates its own engine for writes to `clients`.
|
||||
- MQTT topics (paho-mqtt v2, use Callback API v2):
|
||||
- Discovery: `infoscreen/discovery` (JSON includes `uuid`, hw/ip data). ACK to `infoscreen/{uuid}/discovery_ack`. See `listener/listener.py`.
|
||||
- Heartbeat: `infoscreen/{uuid}/heartbeat` updates `Client.last_alive` (UTC).
|
||||
- Event lists (retained): `infoscreen/events/{group_id}` from `scheduler/scheduler.py`.
|
||||
- Per-client group assignment (retained): `infoscreen/{uuid}/group_id` via `server/mqtt_helper.py`.
|
||||
- Screenshots: server-side folders `server/received_screenshots/` and `server/screenshots/`; Nginx exposes `/screenshots/{uuid}.jpg` via `server/wsgi.py` route.
|
||||
|
||||
- Presentation conversion (PPT/PPTX/ODP → PDF):
|
||||
- Trigger: on upload in `server/routes/eventmedia.py` for media types `ppt|pptx|odp` (compute sha256, upsert `Conversion`, enqueue job).
|
||||
- Worker: RQ worker runs `server.worker.convert_event_media_to_pdf`, calls Gotenberg LibreOffice endpoint, writes to `server/media/converted/`.
|
||||
- Services: Redis (queue) and Gotenberg added in compose; worker service consumes the `conversions` queue.
|
||||
- Env: `REDIS_URL` (default `redis://redis:6379/0`), `GOTENBERG_URL` (default `http://gotenberg:3000`).
|
||||
- Endpoints: `POST /api/conversions/<media_id>/pdf` (ensure/enqueue), `GET /api/conversions/<media_id>/status`, `GET /api/files/converted/<path>` (serve PDFs).
|
||||
- Storage: originals under `server/media/…`, outputs under `server/media/converted/` (prod compose mounts a shared volume for this path).
|
||||
|
||||
## Data model highlights (see `models/models.py`)
|
||||
- Enums: `EventType` (presentation, website, video, message, webuntis), `MediaType` (file/website types), and `AcademicPeriodType` (schuljahr, semester, trimester).
|
||||
- Tables: `clients`, `client_groups`, `events`, `event_media`, `users`, `academic_periods`, `school_holidays`.
|
||||
- Academic periods: `academic_periods` table supports educational institution cycles (school years, semesters). Events and media can be optionally linked via `academic_period_id` (nullable for backward compatibility).
|
||||
- Times are stored as timezone-aware; treat comparisons in UTC (see scheduler and routes/events).
|
||||
|
||||
- Conversions:
|
||||
- Enum `ConversionStatus`: `pending`, `processing`, `ready`, `failed`.
|
||||
- Table `conversions`: `id`, `source_event_media_id` (FK→`event_media.id` ondelete CASCADE), `target_format`, `target_path`, `status`, `file_hash` (sha256), `started_at`, `completed_at`, `error_message`.
|
||||
- Indexes: `(source_event_media_id, target_format)`, `(status, target_format)`; Unique: `(source_event_media_id, target_format, file_hash)`.
|
||||
|
||||
## API patterns
|
||||
- Blueprints live in `server/routes/*` and are registered in `server/wsgi.py` with `/api/...` prefixes.
|
||||
- Session usage: instantiate `Session()` per request, commit when mutating, and always `session.close()` before returning.
|
||||
- Examples:
|
||||
- Clients: `server/routes/clients.py` includes bulk group updates and MQTT sync (`publish_multiple_client_groups`).
|
||||
- Groups: `server/routes/groups.py` computes “alive” using a grace period that varies by `ENV`.
|
||||
- Events: `server/routes/events.py` serializes enum values to strings and normalizes times to UTC.
|
||||
- Media: `server/routes/eventmedia.py` implements a simple file manager API rooted at `server/media/`.
|
||||
- Academic periods: `server/routes/academic_periods.py` exposes:
|
||||
- `GET /api/academic_periods` — list all periods
|
||||
- `GET /api/academic_periods/active` — currently active period
|
||||
- `POST /api/academic_periods/active` — set active period (deactivates others)
|
||||
- `GET /api/academic_periods/for_date?date=YYYY-MM-DD` — period covering given date
|
||||
|
||||
## Frontend patterns (dashboard)
|
||||
- Vite React app; proxies `/api` and `/screenshots` to API in dev (`vite.config.ts`).
|
||||
- Uses Syncfusion components; Vite config pre-bundles specific packages to avoid alias issues.
|
||||
- Environment: `VITE_API_URL` provided at build/run; in dev compose, proxy handles `/api` so local fetches can use relative `/api/...` paths.
|
||||
- Scheduler (appointments page): top bar includes Group and Academic Period selectors (Syncfusion DropDownList). Selecting a period calls `POST /api/academic_periods/active`, moves the calendar to today’s month/day within the period year, and refreshes a right-aligned indicator row showing:
|
||||
- Holidays present in the current view (count)
|
||||
- Period label (display_name or name) with a badge indicating whether any holidays exist in that period (overlap check)
|
||||
|
||||
Note: Syncfusion usage in the dashboard is already documented above; if a UI for conversion status/downloads is added later, link its routes and components here.
|
||||
|
||||
## Local development
|
||||
- Compose: development is `docker-compose.yml` + `docker-compose.override.yml`.
|
||||
- API (dev): `server/Dockerfile.dev` with debugpy on 5678, Flask app `wsgi:app` on :8000.
|
||||
- Dashboard (dev): `dashboard/Dockerfile.dev` exposes :5173 and waits for API via `dashboard/wait-for-backend.sh`.
|
||||
- Mosquitto: allows anonymous in dev; WebSocket on :9001.
|
||||
- Common env vars: `DB_CONN`, `DB_USER`, `DB_PASSWORD`, `DB_HOST=db`, `DB_NAME`, `ENV`, `MQTT_USER`, `MQTT_PASSWORD`.
|
||||
- Alembic: prod compose runs `alembic ... upgrade head` and `server/init_defaults.py` before gunicorn.
|
||||
- Use `server/init_academic_periods.py` to populate default Austrian school years after migration.
|
||||
|
||||
## Production
|
||||
- `docker-compose.prod.yml` uses prebuilt images (`ghcr.io/robbstarkaustria/*`).
|
||||
- Nginx serves dashboard and proxies API; TLS certs expected in `certs/` and mounted to `/etc/nginx/certs`.
|
||||
|
||||
## Environment variables (reference)
|
||||
- DB_CONN — Preferred DB URL for services. Example: `mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}`
|
||||
- DB_USER, DB_PASSWORD, DB_NAME, DB_HOST — Used to assemble DB_CONN in dev if missing; inside containers `DB_HOST=db`.
|
||||
- ENV — `development` or `production`; in development, `server/database.py` loads `.env`.
|
||||
- MQTT_BROKER_HOST, MQTT_BROKER_PORT — Defaults `mqtt` and `1883`; MQTT_USER/MQTT_PASSWORD optional (dev often anonymous per Mosquitto config).
|
||||
- VITE_API_URL — Dashboard build-time base URL (prod); in dev the Vite proxy serves `/api` to `server:8000`.
|
||||
- HEARTBEAT_GRACE_PERIOD_DEV / HEARTBEAT_GRACE_PERIOD_PROD — Groups “alive” window (defaults ~15s dev / 180s prod).
|
||||
- REFRESH_SECONDS — Optional scheduler republish interval; `0` disables periodic refresh.
|
||||
|
||||
## Conventions & gotchas
|
||||
- Always compare datetimes in UTC; some DB values may be naive—normalize before comparing (see `routes/events.py`).
|
||||
- Use retained MQTT messages for state that clients must recover after reconnect (events per group, client group_id).
|
||||
- In-container DB host is `db`; do not use `localhost` inside services.
|
||||
- No separate dev vs prod secret conventions: use the same env var keys across environments (e.g., `DB_CONN`, `MQTT_USER`, `MQTT_PASSWORD`).
|
||||
- When adding a new route:
|
||||
1) Create a Blueprint in `server/routes/...`,
|
||||
2) Register it in `server/wsgi.py`,
|
||||
3) Manage `Session()` lifecycle, and
|
||||
4) Return JSON-safe values (serialize enums and datetimes).
|
||||
- When extending media types, update `MediaType` and any logic in `eventmedia` and dashboard that depends on it.
|
||||
- Academic periods: Events/media can be optionally associated with periods for educational organization. Only one period should be active at a time (`is_active=True`).
|
||||
|
||||
## Quick examples
|
||||
- Add client description persists to DB and publishes group via MQTT: see `PUT /api/clients/<uuid>/description` in `routes/clients.py`.
|
||||
- Bulk group assignment emits retained messages for each client: `PUT /api/clients/group`.
|
||||
- Listener heartbeat path: `infoscreen/<uuid>/heartbeat` → sets `clients.last_alive`.
|
||||
|
||||
Questions or unclear areas? Tell us if you need: exact devcontainer debugging steps, stricter Alembic workflow, or a seed dataset beyond `init_defaults.py`.
|
||||
|
||||
## Academic Periods System
|
||||
- **Purpose**: Organize events and media by educational cycles (school years, semesters, trimesters).
|
||||
- **Design**: Fully backward compatible - existing events/media continue to work without period assignment.
|
||||
- **Usage**: New events/media can optionally reference `academic_period_id` for better organization and filtering.
|
||||
- **Constraints**: Only one period can be active at a time; use `init_academic_periods.py` for Austrian school year setup.
|
||||
- **UI Integration**: The dashboard highlights the currently selected period and whether a holiday plan exists within that date range. Holiday linkage currently uses date overlap with `school_holidays`; an explicit `academic_period_id` on `school_holidays` can be added later if tighter association is required.
|
||||
6
.stylelintrc.json
Normal file
6
.stylelintrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": [
|
||||
"stylelint-config-standard",
|
||||
"stylelint-config-tailwindcss"
|
||||
]
|
||||
}
|
||||
100
AI-INSTRUCTIONS-MAINTENANCE.md
Normal file
100
AI-INSTRUCTIONS-MAINTENANCE.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# 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)
|
||||
|
||||
## 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.
|
||||
|
||||
## 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`
|
||||
39
CLEANUP_SUMMARY.md
Normal file
39
CLEANUP_SUMMARY.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Database Cleanup Summary
|
||||
|
||||
## Files Removed ✅
|
||||
|
||||
The following obsolete database initialization files have been removed:
|
||||
|
||||
### Removed Files:
|
||||
- **`server/init_database.py`** - Manual table creation (superseded by Alembic migrations)
|
||||
- **`server/init_db.py`** - Alternative initialization (superseded by `init_defaults.py`)
|
||||
- **`server/init_mariadb.py`** - Database/user creation (handled by Docker Compose)
|
||||
- **`server/test_sql.py`** - Outdated connection test (used localhost instead of container)
|
||||
|
||||
### Why These Were Safe to Remove:
|
||||
1. **No references found** in any Docker files, scripts, or code
|
||||
2. **Functionality replaced** by modern Alembic-based approach
|
||||
3. **Hardcoded connection strings** that don't match current Docker setup
|
||||
4. **Manual processes** now automated in production deployment
|
||||
|
||||
## Current Database Management ✅
|
||||
|
||||
### Active Scripts:
|
||||
- **`server/initialize_database.py`** - Complete initialization (NEW)
|
||||
- **`server/init_defaults.py`** - Default data creation
|
||||
- **`server/init_academic_periods.py`** - Academic periods setup
|
||||
- **`alembic/`** - Schema migrations (version control)
|
||||
|
||||
### Development Scripts (Kept):
|
||||
- **`server/dummy_clients.py`** - Test client data generation
|
||||
- **`server/dummy_events.py`** - Test event data generation
|
||||
- **`server/sync_existing_clients.py`** - MQTT synchronization utility
|
||||
|
||||
## Result
|
||||
|
||||
- **4 obsolete files removed**
|
||||
- **Documentation updated** to reflect current state
|
||||
- **No breaking changes** - all functionality preserved
|
||||
- **Cleaner codebase** with single initialization path
|
||||
|
||||
The database initialization process is now streamlined and uses only modern, maintained approaches.
|
||||
147
DATABASE_GUIDE.md
Normal file
147
DATABASE_GUIDE.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# 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 (Alembic revision: `b5a6c3d4e7f8`)
|
||||
- **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
|
||||
- **`alembic_version`** - Migration tracking
|
||||
|
||||
### 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.
|
||||
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"
|
||||
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"
|
||||
407
README.md
Normal file
407
README.md
Normal file
@@ -0,0 +1,407 @@
|
||||
# Infoscreen 2025
|
||||
|
||||
[](https://www.docker.com/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://flask.palletsprojects.com/)
|
||||
[](https://mariadb.org/)
|
||||
[](https://mosquitto.org/)
|
||||
|
||||
A comprehensive multi-service digital signage solution for educational institutions, featuring client management, event scheduling, presentation conversion, and real-time MQTT communication.
|
||||
|
||||
## 🏗️ Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Dashboard │ │ API Server │ │ Listener │
|
||||
│ (React/Vite) │◄──►│ (Flask) │◄──►│ (MQTT Client) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ MariaDB │ │
|
||||
│ │ (Database) │ │
|
||||
│ └─────────────────┘ │
|
||||
│ │
|
||||
└────────────────────┬───────────────────────────┘
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ MQTT Broker │
|
||||
│ (Mosquitto) │
|
||||
└─────────────────┘
|
||||
│
|
||||
┌────────────────────┼────────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Scheduler │ │ Worker │ │ Infoscreen │
|
||||
│ (Events) │ │ (Conversions) │ │ Clients │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## 🌟 Key Features
|
||||
|
||||
### 📊 **Dashboard Management**
|
||||
- Modern React-based web interface with Syncfusion components
|
||||
- Real-time client monitoring and group management
|
||||
- Event scheduling with academic period support
|
||||
- Media management with presentation conversion
|
||||
- Holiday calendar integration
|
||||
|
||||
### 🎯 **Event System**
|
||||
- **Presentations**: PowerPoint/LibreOffice → PDF conversion via Gotenberg
|
||||
- **Websites**: URL-based content display
|
||||
- **Videos**: Media file streaming
|
||||
- **Messages**: Text announcements
|
||||
- **WebUntis**: Educational schedule integration
|
||||
|
||||
### 🏫 **Academic Period Management**
|
||||
- Support for school years, semesters, and trimesters
|
||||
- Austrian school system integration
|
||||
- Holiday calendar synchronization
|
||||
- Period-based event organization
|
||||
|
||||
### 📡 **Real-time Communication**
|
||||
- MQTT-based client discovery and heartbeat monitoring
|
||||
- Retained topics for reliable state synchronization
|
||||
- WebSocket support for browser clients
|
||||
- Automatic client group assignment
|
||||
|
||||
### 🔄 **Background Processing**
|
||||
- Redis-based job queues for presentation conversion
|
||||
- Gotenberg integration for LibreOffice/PowerPoint processing
|
||||
- Asynchronous file processing with status tracking
|
||||
- RQ (Redis Queue) worker management
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Docker & Docker Compose
|
||||
- Git
|
||||
- SSL certificates (for production)
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd infoscreen_2025
|
||||
```
|
||||
|
||||
2. **Environment Configuration**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your configuration
|
||||
```
|
||||
|
||||
3. **Start the development stack**
|
||||
```bash
|
||||
make up
|
||||
# or: docker compose up -d --build
|
||||
```
|
||||
|
||||
4. **Access the services**
|
||||
- Dashboard: http://localhost:5173
|
||||
- API: http://localhost:8000
|
||||
- Database: localhost:3306
|
||||
- MQTT: localhost:1883 (WebSocket: 9001)
|
||||
|
||||
### Production Deployment
|
||||
|
||||
1. **Build and push images**
|
||||
```bash
|
||||
make build
|
||||
make push
|
||||
```
|
||||
|
||||
2. **Deploy on server**
|
||||
```bash
|
||||
make pull-prod
|
||||
make up-prod
|
||||
```
|
||||
|
||||
For detailed deployment instructions, see:
|
||||
- [Debian Deployment Guide](deployment-debian.md)
|
||||
- [Ubuntu Deployment Guide](deployment-ubuntu.md)
|
||||
|
||||
## 🛠️ Services
|
||||
|
||||
### 🖥️ **Dashboard** (`dashboard/`)
|
||||
- **Technology**: React 19 + TypeScript + Vite
|
||||
- **UI Framework**: Syncfusion components + Tailwind CSS
|
||||
- **Features**: Responsive design, real-time updates, file management
|
||||
- **Port**: 5173 (dev), served via Nginx (prod)
|
||||
|
||||
### 🔧 **API Server** (`server/`)
|
||||
- **Technology**: Flask + SQLAlchemy + Alembic
|
||||
- **Database**: MariaDB with timezone-aware timestamps
|
||||
- **Features**: RESTful API, file uploads, MQTT integration
|
||||
- **Port**: 8000
|
||||
- **Health Check**: `/health`
|
||||
|
||||
### 👂 **Listener** (`listener/`)
|
||||
- **Technology**: Python + paho-mqtt
|
||||
- **Purpose**: MQTT message processing, client discovery
|
||||
- **Features**: Heartbeat monitoring, automatic client registration
|
||||
|
||||
### ⏰ **Scheduler** (`scheduler/`)
|
||||
- **Technology**: Python + SQLAlchemy
|
||||
- **Purpose**: Event publishing, group-based content distribution
|
||||
- **Features**: Time-based event activation, MQTT publishing
|
||||
|
||||
### 🔄 **Worker** (Conversion Service)
|
||||
- **Technology**: RQ (Redis Queue) + Gotenberg
|
||||
- **Purpose**: Background presentation conversion
|
||||
- **Features**: PPT/PPTX/ODP → PDF conversion, status tracking
|
||||
|
||||
### 🗄️ **Database** (MariaDB 11.2)
|
||||
- **Features**: Health checks, automatic initialization
|
||||
- **Migrations**: Alembic-based schema management
|
||||
- **Timezone**: UTC-aware timestamps
|
||||
|
||||
### 📡 **MQTT Broker** (Eclipse Mosquitto 2.0.21)
|
||||
- **Features**: WebSocket support, health monitoring
|
||||
- **Topics**:
|
||||
- `infoscreen/discovery` - Client registration
|
||||
- `infoscreen/{uuid}/heartbeat` - Client alive status
|
||||
- `infoscreen/events/{group_id}` - Event distribution
|
||||
- `infoscreen/{uuid}/group_id` - Client group assignment
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
infoscreen_2025/
|
||||
├── dashboard/ # React frontend
|
||||
│ ├── src/ # React components and logic
|
||||
│ ├── public/ # Static assets
|
||||
│ └── Dockerfile # Production build
|
||||
├── server/ # Flask API backend
|
||||
│ ├── routes/ # API endpoints
|
||||
│ ├── alembic/ # Database migrations
|
||||
│ ├── media/ # File storage
|
||||
│ └── worker.py # Background jobs
|
||||
├── listener/ # MQTT listener service
|
||||
├── scheduler/ # Event scheduling service
|
||||
├── models/ # Shared database models
|
||||
├── mosquitto/ # MQTT broker configuration
|
||||
├── certs/ # SSL certificates
|
||||
├── docker-compose.yml # Development setup
|
||||
├── docker-compose.prod.yml # Production setup
|
||||
└── Makefile # Development shortcuts
|
||||
```
|
||||
|
||||
## 🔧 Development
|
||||
|
||||
### Available Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
make up # Start dev stack
|
||||
make down # Stop dev stack
|
||||
make logs # View all logs
|
||||
make logs-server # View specific service logs
|
||||
|
||||
# Building & Deployment
|
||||
make build # Build all images
|
||||
make push # Push to registry
|
||||
make pull-prod # Pull production images
|
||||
make up-prod # Start production stack
|
||||
|
||||
# Maintenance
|
||||
make health # Health checks
|
||||
make fix-perms # Fix file permissions
|
||||
```
|
||||
|
||||
### Database Management
|
||||
|
||||
```bash
|
||||
# Access database directly
|
||||
docker exec -it infoscreen-db mysql -u${DB_USER} -p${DB_PASSWORD} ${DB_NAME}
|
||||
|
||||
# Run migrations
|
||||
docker exec -it infoscreen-api alembic upgrade head
|
||||
|
||||
# Initialize academic periods (Austrian school system)
|
||||
docker exec -it infoscreen-api python init_academic_periods.py
|
||||
```
|
||||
|
||||
### MQTT Testing
|
||||
|
||||
```bash
|
||||
# Subscribe to all topics
|
||||
mosquitto_sub -h localhost -t "infoscreen/#" -v
|
||||
|
||||
# Publish test message
|
||||
mosquitto_pub -h localhost -t "infoscreen/test" -m "Hello World"
|
||||
|
||||
# Monitor client heartbeats
|
||||
mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
|
||||
```
|
||||
|
||||
## 🌐 API Endpoints
|
||||
|
||||
### Core Resources
|
||||
- `GET /api/clients` - List all registered clients
|
||||
- `PUT /api/clients/{uuid}/group` - Assign client to group
|
||||
- `GET /api/groups` - List client groups with alive status
|
||||
- `GET /api/events` - List events with filtering
|
||||
- `POST /api/events` - Create new event
|
||||
- `GET /api/academic_periods` - List academic periods
|
||||
- `POST /api/academic_periods/active` - Set active period
|
||||
|
||||
### File Management
|
||||
- `POST /api/files` - Upload media files
|
||||
- `GET /api/files/{path}` - Download files
|
||||
- `GET /api/files/converted/{path}` - Download converted PDFs
|
||||
- `POST /api/conversions/{media_id}/pdf` - Request conversion
|
||||
- `GET /api/conversions/{media_id}/status` - Check conversion status
|
||||
|
||||
### Health & Monitoring
|
||||
- `GET /health` - Service health check
|
||||
- `GET /api/screenshots/{uuid}.jpg` - Client screenshots
|
||||
|
||||
## 🎨 Frontend Features
|
||||
|
||||
### Syncfusion Components Used
|
||||
- **Schedule**: Event calendar with drag-drop support
|
||||
- **Grid**: Data tables with filtering and sorting
|
||||
- **DropDownList**: Group and period selectors
|
||||
- **FileManager**: Media upload and organization
|
||||
- **Kanban**: Task management views
|
||||
- **Notifications**: Toast messages and alerts
|
||||
|
||||
### Pages Overview
|
||||
- **Dashboard**: System overview and statistics
|
||||
- **Clients**: Device management and monitoring
|
||||
- **Groups**: Client group organization
|
||||
- **Events**: Schedule management
|
||||
- **Media**: File upload and conversion
|
||||
- **Settings**: System configuration
|
||||
- **Holidays**: Academic calendar management
|
||||
|
||||
## 🔒 Security & Authentication
|
||||
|
||||
- **Environment Variables**: Sensitive data via `.env`
|
||||
- **SSL/TLS**: HTTPS support with custom certificates
|
||||
- **MQTT Security**: Username/password authentication
|
||||
- **Database**: Parameterized queries, connection pooling
|
||||
- **File Uploads**: Type validation, size limits
|
||||
- **CORS**: Configured for production deployment
|
||||
|
||||
## 📊 Monitoring & Logging
|
||||
|
||||
### Health Checks
|
||||
All services include Docker health checks:
|
||||
- API: HTTP endpoint monitoring
|
||||
- Database: Connection and initialization status
|
||||
- MQTT: Pub/sub functionality test
|
||||
- Dashboard: Nginx availability
|
||||
|
||||
### Logging Strategy
|
||||
- **Development**: Docker Compose logs with service prefixes
|
||||
- **Production**: Centralized logging via Docker log drivers
|
||||
- **MQTT**: Message-level debugging available
|
||||
- **Database**: Query logging in development mode
|
||||
|
||||
## 🌍 Deployment Options
|
||||
|
||||
### Development
|
||||
- **Hot Reload**: Vite dev server + Flask debug mode
|
||||
- **Volume Mounts**: Live code editing
|
||||
- **Debug Ports**: Python debugger support (port 5678)
|
||||
- **Local Certificates**: Self-signed SSL for testing
|
||||
|
||||
### Production
|
||||
- **Optimized Builds**: Multi-stage Dockerfiles
|
||||
- **Reverse Proxy**: Nginx with SSL termination
|
||||
- **Health Monitoring**: Comprehensive healthchecks
|
||||
- **Registry**: GitHub Container Registry integration
|
||||
- **Scaling**: Docker Compose for single-node deployment
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch: `git checkout -b feature/amazing-feature`
|
||||
3. Commit your changes: `git commit -m 'Add amazing feature'`
|
||||
4. Push to the branch: `git push origin feature/amazing-feature`
|
||||
5. Open a Pull Request
|
||||
|
||||
### Development Guidelines
|
||||
- Follow existing code patterns and naming conventions
|
||||
- Add appropriate tests for new features
|
||||
- Update documentation for API changes
|
||||
- Use TypeScript for frontend development
|
||||
- Follow Python PEP 8 for backend code
|
||||
|
||||
## 📋 Requirements
|
||||
|
||||
### System Requirements
|
||||
- **CPU**: 2+ cores recommended
|
||||
- **RAM**: 4GB minimum, 8GB recommended
|
||||
- **Storage**: 20GB+ for media files and database
|
||||
- **Network**: Reliable internet for client communication
|
||||
|
||||
### Software Dependencies
|
||||
- Docker 24.0+
|
||||
- Docker Compose 2.0+
|
||||
- Git 2.30+
|
||||
- Modern web browser (Chrome, Firefox, Safari, Edge)
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Services won't start**
|
||||
```bash
|
||||
# Check service health
|
||||
make health
|
||||
|
||||
# View specific service logs
|
||||
make logs-server
|
||||
make logs-db
|
||||
```
|
||||
|
||||
**Database connection errors**
|
||||
```bash
|
||||
# Verify database is running
|
||||
docker exec -it infoscreen-db mysqladmin ping
|
||||
|
||||
# Check credentials in .env file
|
||||
# Restart dependent services
|
||||
```
|
||||
|
||||
**MQTT communication issues**
|
||||
```bash
|
||||
# Test MQTT broker
|
||||
mosquitto_pub -h localhost -t test -m "hello"
|
||||
|
||||
# Check client certificates and credentials
|
||||
# Verify firewall settings for ports 1883/9001
|
||||
```
|
||||
|
||||
**File conversion problems**
|
||||
```bash
|
||||
# Check Gotenberg service
|
||||
curl http://localhost:3000/health
|
||||
|
||||
# Monitor worker logs
|
||||
make logs-worker
|
||||
|
||||
# Check Redis queue status
|
||||
docker exec -it infoscreen-redis redis-cli LLEN conversions
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- **Syncfusion**: UI components for React dashboard
|
||||
- **Eclipse Mosquitto**: MQTT broker implementation
|
||||
- **Gotenberg**: Document conversion service
|
||||
- **MariaDB**: Reliable database engine
|
||||
- **Flask**: Python web framework
|
||||
- **React**: Frontend user interface library
|
||||
|
||||
---
|
||||
|
||||
For detailed technical documentation, deployment guides, and API specifications, please refer to the additional documentation files in this repository.
|
||||
0
__init__.py
Normal file
0
__init__.py
Normal file
17
create_init_files.py
Normal file
17
create_init_files.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import os
|
||||
|
||||
folders = [
|
||||
"server",
|
||||
"dashboard",
|
||||
"dashboard/callbacks",
|
||||
"dashboard/utils",
|
||||
]
|
||||
|
||||
for folder in folders:
|
||||
path = os.path.join(os.getcwd(), folder, "__init__.py")
|
||||
if not os.path.exists(path):
|
||||
with open(path, "w") as f:
|
||||
pass # Leere Datei anlegen
|
||||
print(f"Angelegt: {path}")
|
||||
else:
|
||||
print(f"Existiert bereits: {path}")
|
||||
1
dashboard/.dockerignore
Normal file
1
dashboard/.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
34
dashboard/.eslintrc.cjs
Normal file
34
dashboard/.eslintrc.cjs
Normal file
@@ -0,0 +1,34 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended'
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
plugins: ['react', '@typescript-eslint'],
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Beispiele für sinnvolle Anpassungen
|
||||
'react/react-in-jsx-scope': 'off', // nicht nötig mit React 17+
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
};
|
||||
9
dashboard/.prettierrc
Normal file
9
dashboard/.prettierrc
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
9
dashboard/.stylelintrc.json
Normal file
9
dashboard/.stylelintrc.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": [
|
||||
"stylelint-config-standard",
|
||||
"stylelint-config-tailwindcss"
|
||||
],
|
||||
"rules": {
|
||||
"at-rule-no-unknown": null
|
||||
}
|
||||
}
|
||||
25
dashboard/Dockerfile
Normal file
25
dashboard/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
# ==========================================
|
||||
# dashboard/Dockerfile (Production)
|
||||
# ==========================================
|
||||
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Kopiere package.json und Lockfile aus dem Build-Kontext (./dashboard)
|
||||
COPY package*.json ./
|
||||
|
||||
# Produktions-Abhängigkeiten installieren
|
||||
ENV NODE_ENV=production
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Quellcode kopieren und builden
|
||||
COPY . .
|
||||
ARG VITE_API_URL
|
||||
ENV VITE_API_URL=${VITE_API_URL}
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.25-alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", " -g", "daemon off;"]
|
||||
|
||||
28
dashboard/Dockerfile.dev
Normal file
28
dashboard/Dockerfile.dev
Normal file
@@ -0,0 +1,28 @@
|
||||
# ==========================================
|
||||
# dashboard/Dockerfile.dev (Development)
|
||||
# 🔧 OPTIMIERT: Für schnelle Entwicklung mit Vite und npm
|
||||
# ==========================================
|
||||
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
|
||||
|
||||
# KOPIEREN: Nur package-Dateien relativ zum Build-Kontext (KEINE /workspace-Pfade)
|
||||
# package*.json deckt sowohl package.json als auch package-lock.json ab, falls vorhanden
|
||||
COPY package*.json ./
|
||||
|
||||
# Installation robust machen: npm ci erfordert package-lock.json; fallback auf 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
|
||||
|
||||
EXPOSE 5173 9230
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
|
||||
54
dashboard/README.md
Normal file
54
dashboard/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
extends: [
|
||||
// Remove ...tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default tseslint.config({
|
||||
plugins: {
|
||||
// Add the react-x and react-dom plugins
|
||||
'react-x': reactX,
|
||||
'react-dom': reactDom,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended typescript rules
|
||||
...reactX.configs['recommended-typescript'].rules,
|
||||
...reactDom.configs.recommended.rules,
|
||||
},
|
||||
})
|
||||
```
|
||||
28
dashboard/eslint.config.js
Normal file
28
dashboard/eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
13
dashboard/index.html
Normal file
13
dashboard/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
8177
dashboard/package-lock.json
generated
Normal file
8177
dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
75
dashboard/package.json
Normal file
75
dashboard/package.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "dashboard",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@syncfusion/ej2-base": "^30.2.0",
|
||||
"@syncfusion/ej2-buttons": "^30.2.0",
|
||||
"@syncfusion/ej2-calendars": "^30.2.0",
|
||||
"@syncfusion/ej2-dropdowns": "^30.2.0",
|
||||
"@syncfusion/ej2-grids": "^30.2.0",
|
||||
"@syncfusion/ej2-icons": "^30.2.0",
|
||||
"@syncfusion/ej2-inputs": "^30.2.0",
|
||||
"@syncfusion/ej2-kanban": "^30.2.0",
|
||||
"@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-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-splitbuttons": "^30.2.0",
|
||||
"cldr-data": "^36.0.4",
|
||||
"lucide-react": "^0.522.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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-dom": "^19.1.6",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.34.1",
|
||||
"@typescript-eslint/parser": "^8.34.1",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-prettier": "^5.5.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.5.3",
|
||||
"stylelint": "^16.21.0",
|
||||
"stylelint-config-standard": "^38.0.0",
|
||||
"stylelint-config-tailwindcss": "^1.0.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
6
dashboard/postcss.config.cjs
Normal file
6
dashboard/postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
96
dashboard/public/program-info.json
Normal file
96
dashboard/public/program-info.json
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"appName": "Infoscreen-Management",
|
||||
"version": "2025.1.0-alpha.7",
|
||||
"copyright": "© 2025 Third-Age-Applications",
|
||||
"supportContact": "support@third-age-applications.com",
|
||||
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
|
||||
"techStack": {
|
||||
"Frontend": "React, Vite, TypeScript",
|
||||
"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" }
|
||||
]
|
||||
},
|
||||
"buildInfo": {
|
||||
"buildDate": "2025-09-20T11:00:00Z",
|
||||
"commitId": "8d1df7199cb7"
|
||||
},
|
||||
"changelog": [
|
||||
{
|
||||
"version": "2025.1.0-alpha.7",
|
||||
"date": "2025-09-21",
|
||||
"changes": [
|
||||
"🧭 UI: Periode-Auswahl (Syncfusion) neben Gruppenauswahl; kompaktes Layout",
|
||||
"✅ Anzeige: Abzeichen für vorhandenen Ferienplan + Zähler ‘Ferien im Blick’",
|
||||
"🛠️ API: Endpunkte für akademische Perioden (list, active GET/POST, for_date)",
|
||||
"📅 Scheduler: Standardmäßig keine Terminierung in Ferien; Block-Darstellung wie Ganztagesereignis; schwarze Textfarbe",
|
||||
"📤 Ferien: Upload von TXT/CSV (headless TXT nutzt Spalten 2–4)",
|
||||
"🔧 UX: Schalter in einer Reihe; Dropdown-Breiten optimiert"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2025.1.0-alpha.6",
|
||||
"date": "2025-09-20",
|
||||
"changes": [
|
||||
"🗓️ NEU: Akademische Perioden System - Unterstützung für Schuljahre, Semester und Trimester",
|
||||
"🏗️ DATENBANK: Neue 'academic_periods' Tabelle für zeitbasierte Organisation",
|
||||
"🔗 ERWEITERT: Events und Medien können jetzt optional einer akademischen Periode zugeordnet werden",
|
||||
"📊 ARCHITEKTUR: Vollständig rückwärtskompatible Implementierung für schrittweise Einführung",
|
||||
"🎯 BILDUNG: Fokus auf Schulumgebung mit Erweiterbarkeit für Hochschulen",
|
||||
"⚙️ TOOLS: Automatische Erstellung von Standard-Schuljahren für österreichische Schulen"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2025.1.0-alpha.5",
|
||||
"date": "2025-09-14",
|
||||
"changes": [
|
||||
"Komplettes Redesign des Backend-Handlings der Gruppenzuordnungen von neuen Clients und der Schritte bei Änderung der Gruppenzuordnung."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2025.1.0-alpha.4",
|
||||
"date": "2025-09-01",
|
||||
"changes": [
|
||||
"Grundstruktur für Deployment getestet und optimiert.",
|
||||
"FIX: Programmfehler beim Umschalten der Ansicht auf der Medien-Seite behoben."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2025.1.0-alpha.3",
|
||||
"date": "2025-08-30",
|
||||
"changes": [
|
||||
"NEU: Programminfo-Seite mit dynamischen Daten, Build-Infos und Changelog.",
|
||||
"NEU: Logout-Funktionalität implementiert.",
|
||||
"FIX: Breite der Sidebar im eingeklappten Zustand korrigiert."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2025.1.0-alpha.2",
|
||||
"date": "2025-08-29",
|
||||
"changes": [
|
||||
"INFO: Analyse und Anzeige der verwendeten Open-Source-Bibliotheken."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2025.1.0-alpha.1",
|
||||
"date": "2025-08-28",
|
||||
"changes": [
|
||||
"Initiales Setup des Projekts und der Grundstruktur."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1
dashboard/public/vite.svg
Normal file
1
dashboard/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
275
dashboard/src/App.css
Normal file
275
dashboard/src/App.css
Normal file
@@ -0,0 +1,275 @@
|
||||
@import "../node_modules/@syncfusion/ej2-base/styles/material.css";
|
||||
@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 {
|
||||
font-family: Inter, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||
overflow: hidden; /* Verhindert den Scrollbalken auf der obersten Ebene */
|
||||
}
|
||||
|
||||
:root {
|
||||
--sidebar-bg: #e5d8c7;
|
||||
--sidebar-fg: #78591c;
|
||||
--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 {
|
||||
background-color: var(--sidebar-bg);
|
||||
color: var(--sidebar-text);
|
||||
font-size: 1.15rem;
|
||||
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 {
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex !important;
|
||||
width: 100% !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sidebar-theme .sidebar-logout {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
font-size: 1.15rem;
|
||||
display: flex !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.sidebar-link:hover,
|
||||
.sidebar-logout:hover {
|
||||
background-color: var(--sidebar-hover-bg);
|
||||
color: var(--sidebar-hover-text);
|
||||
}
|
||||
|
||||
.sidebar-link.active {
|
||||
background-color: var(--sidebar-active-bg);
|
||||
color: var(--sidebar-active-text);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* === 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 */
|
||||
.e-kanban .e-card,
|
||||
.e-kanban .e-card .e-card-content,
|
||||
.e-kanban .e-card .e-card-header {
|
||||
background-color: var(--sidebar-bg) !important;
|
||||
color: var(--sidebar-fg) !important;
|
||||
}
|
||||
|
||||
.e-kanban .e-card:hover,
|
||||
.e-kanban .e-card.e-selection,
|
||||
.e-kanban .e-card.e-card-active,
|
||||
.e-kanban .e-card:hover .e-card-content,
|
||||
.e-kanban .e-card.e-selection .e-card-content,
|
||||
.e-kanban .e-card.e-card-active .e-card-content,
|
||||
.e-kanban .e-card:hover .e-card-header,
|
||||
.e-kanban .e-card.e-selection .e-card-header,
|
||||
.e-kanban .e-card.e-card-active .e-card-header {
|
||||
background-color: var(--sidebar-fg) !important;
|
||||
color: var(--sidebar-bg) !important;
|
||||
}
|
||||
|
||||
/* Optional: Fokus-Style für Tastatur-Navigation */
|
||||
.e-kanban .e-card:focus {
|
||||
outline: 2px solid var(--sidebar-fg);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Kanban-Spaltenheader: Hintergrund und Textfarbe überschreiben */
|
||||
.e-kanban .e-kanban-table .e-header-cells {
|
||||
background-color: color-mix(in srgb, var(--sidebar-bg) 80%, #fff 20%) !important;
|
||||
color: var(--sidebar-fg) !important;
|
||||
font-weight: 700;
|
||||
font-size: 1.08rem;
|
||||
border-bottom: 2px solid var(--sidebar-fg);
|
||||
box-shadow: 0 2px 6px 0 color-mix(in srgb, #78591c 8%, transparent);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Header-Text noch spezifischer and mit !important */
|
||||
.e-kanban .e-kanban-table .e-header-cells .e-header-text {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
337
dashboard/src/App.tsx
Normal file
337
dashboard/src/App.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Link, Outlet } from 'react-router-dom';
|
||||
import { SidebarComponent } from '@syncfusion/ej2-react-navigations';
|
||||
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
|
||||
import { TooltipComponent } from '@syncfusion/ej2-react-popups';
|
||||
import logo from './assets/logo.png';
|
||||
import './App.css';
|
||||
|
||||
// Lucide Icons importieren
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Calendar,
|
||||
Boxes,
|
||||
Image,
|
||||
User,
|
||||
Settings,
|
||||
Monitor,
|
||||
MonitorDotIcon,
|
||||
LogOut,
|
||||
Wrench,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import { ToastProvider } from './components/ToastProvider';
|
||||
|
||||
const sidebarItems = [
|
||||
{ name: 'Dashboard', path: '/', icon: LayoutDashboard },
|
||||
{ name: 'Termine', path: '/termine', icon: Calendar },
|
||||
{ name: 'Ressourcen', path: '/ressourcen', icon: Boxes },
|
||||
{ name: 'Raumgruppen', path: '/infoscr_groups', icon: MonitorDotIcon },
|
||||
{ name: 'Infoscreen-Clients', path: '/clients', icon: Monitor },
|
||||
{ name: 'Erweiterungsmodus', path: '/setup', icon: Wrench },
|
||||
{ name: 'Medien', path: '/medien', icon: Image },
|
||||
{ name: 'Benutzer', path: '/benutzer', icon: User },
|
||||
{ name: 'Einstellungen', path: '/einstellungen', icon: Settings },
|
||||
{ name: 'Programminfo', path: '/programminfo', icon: Info },
|
||||
];
|
||||
|
||||
// Dummy Components (können in eigene Dateien ausgelagert werden)
|
||||
import Dashboard from './dashboard';
|
||||
import Appointments from './appointments';
|
||||
import Ressourcen from './ressourcen';
|
||||
import Infoscreens from './clients';
|
||||
import Infoscreen_groups from './infoscreen_groups';
|
||||
import Media from './media';
|
||||
import Benutzer from './benutzer';
|
||||
import Einstellungen from './einstellungen';
|
||||
import SetupMode from './SetupMode';
|
||||
import Programminfo from './programminfo';
|
||||
import Logout from './logout';
|
||||
|
||||
// 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);
|
||||
let sidebarRef: SidebarComponent | null;
|
||||
|
||||
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));
|
||||
}, []);
|
||||
|
||||
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 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.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>
|
||||
<span className="ml-auto text-lg font-medium" style={{ color: '#78591c' }}>
|
||||
[Organisationsname]
|
||||
</span>
|
||||
</header>
|
||||
<main className="page-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
// Automatische Navigation zu /clients bei leerer Beschreibung entfernt
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<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="setup" element={<SetupMode />} />
|
||||
<Route path="programminfo" element={<Programminfo />} />
|
||||
</Route>
|
||||
<Route path="/logout" element={<Logout />} />
|
||||
</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;
|
||||
42
dashboard/src/apiAcademicPeriods.ts
Normal file
42
dashboard/src/apiAcademicPeriods.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type AcademicPeriod = {
|
||||
id: number;
|
||||
name: string;
|
||||
display_name?: string | null;
|
||||
start_date: string; // YYYY-MM-DD
|
||||
end_date: string; // YYYY-MM-DD
|
||||
period_type: 'schuljahr' | 'semester' | 'trimester';
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
async function api<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, { credentials: 'include', ...init });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getAcademicPeriodForDate(date: Date): Promise<AcademicPeriod | null> {
|
||||
const iso = date.toISOString().slice(0, 10);
|
||||
const { period } = await api<{ period: AcademicPeriod | null }>(
|
||||
`/api/academic_periods/for_date?date=${iso}`
|
||||
);
|
||||
return period ?? null;
|
||||
}
|
||||
|
||||
export async function listAcademicPeriods(): Promise<AcademicPeriod[]> {
|
||||
const { periods } = await api<{ periods: AcademicPeriod[] }>(`/api/academic_periods`);
|
||||
return Array.isArray(periods) ? periods : [];
|
||||
}
|
||||
|
||||
export async function getActiveAcademicPeriod(): Promise<AcademicPeriod | null> {
|
||||
const { period } = await api<{ period: AcademicPeriod | null }>(`/api/academic_periods/active`);
|
||||
return period ?? null;
|
||||
}
|
||||
|
||||
export async function setActiveAcademicPeriod(id: number): Promise<AcademicPeriod> {
|
||||
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/active`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
return period;
|
||||
}
|
||||
105
dashboard/src/apiClients.ts
Normal file
105
dashboard/src/apiClients.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
export interface Client {
|
||||
uuid: string;
|
||||
hardware_token?: string;
|
||||
ip?: string;
|
||||
type?: string;
|
||||
hostname?: string;
|
||||
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[];
|
||||
}
|
||||
// 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[]> {
|
||||
const response = await fetch('/api/clients');
|
||||
if (!response.ok) {
|
||||
throw new Error('Fehler beim Laden der Clients');
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
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', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ client_ids: clientIds, group_id: groupId }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Aktualisieren der Clients');
|
||||
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): Promise<{ success: boolean; message?: string }> {
|
||||
const response = await fetch(`/api/clients/${uuid}/restart`, {
|
||||
method: 'POST',
|
||||
});
|
||||
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 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();
|
||||
}
|
||||
42
dashboard/src/apiEvents.ts
Normal file
42
dashboard/src/apiEvents.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export interface Event {
|
||||
id: string;
|
||||
title: string;
|
||||
start: string;
|
||||
end: string;
|
||||
allDay: boolean;
|
||||
classNames: string[];
|
||||
extendedProps: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function fetchEvents(groupId: string, showInactive = false) {
|
||||
const res = await fetch(
|
||||
`/api/events?group_id=${encodeURIComponent(groupId)}&show_inactive=${showInactive ? '1' : '0'}`
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Termine');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteEvent(eventId: string) {
|
||||
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Löschen des Termins');
|
||||
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;
|
||||
}
|
||||
40
dashboard/src/apiGroups.ts
Normal file
40
dashboard/src/apiGroups.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export async function createGroup(name: string) {
|
||||
const res = await fetch('/api/groups', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Erstellen der Gruppe');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchGroups() {
|
||||
const res = await fetch('/api/groups');
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Gruppen');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteGroup(groupName: string) {
|
||||
const res = await fetch(`/api/groups/byname/${encodeURIComponent(groupName)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Löschen der Gruppe');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function renameGroup(oldName: string, newName: string) {
|
||||
const res = await fetch(`/api/groups/byname/${encodeURIComponent(oldName)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ newName: newName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Umbenennen der Gruppe');
|
||||
return data;
|
||||
}
|
||||
|
||||
// Hier kannst du später weitere Funktionen ergänzen:
|
||||
// export async function updateGroup(id: number, name: string) { ... }
|
||||
26
dashboard/src/apiHolidays.ts
Normal file
26
dashboard/src/apiHolidays.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type Holiday = {
|
||||
id: number;
|
||||
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) {
|
||||
const url = region ? `/api/holidays?region=${encodeURIComponent(region)}` : '/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) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
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 };
|
||||
}
|
||||
813
dashboard/src/appointments.tsx
Normal file
813
dashboard/src/appointments.tsx
Normal file
@@ -0,0 +1,813 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ScheduleComponent,
|
||||
Day,
|
||||
Week,
|
||||
WorkWeek,
|
||||
Month,
|
||||
Agenda,
|
||||
Inject,
|
||||
ViewsDirective,
|
||||
ViewDirective,
|
||||
} from '@syncfusion/ej2-react-schedule';
|
||||
import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns';
|
||||
import { L10n, loadCldr, setCulture } from '@syncfusion/ej2-base';
|
||||
import type {
|
||||
EventRenderedArgs,
|
||||
ActionEventArgs,
|
||||
RenderCellEventArgs,
|
||||
} from '@syncfusion/ej2-react-schedule';
|
||||
import { fetchEvents } from './apiEvents';
|
||||
import { fetchGroups } from './apiGroups';
|
||||
import { getGroupColor } from './groupColors';
|
||||
import { deleteEvent } from './apiEvents';
|
||||
import CustomEventModal from './components/CustomEventModal';
|
||||
import { fetchMediaById } from './apiClients';
|
||||
import { listHolidays, type Holiday } from './apiHolidays';
|
||||
import {
|
||||
getAcademicPeriodForDate,
|
||||
listAcademicPeriods,
|
||||
setActiveAcademicPeriod,
|
||||
} from './apiAcademicPeriods';
|
||||
import {
|
||||
Presentation,
|
||||
Globe,
|
||||
Video,
|
||||
MessageSquare,
|
||||
School,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import caGregorian from './cldr/ca-gregorian.json';
|
||||
import numbers from './cldr/numbers.json';
|
||||
import timeZoneNames from './cldr/timeZoneNames.json';
|
||||
import numberingSystems from './cldr/numberingSystems.json';
|
||||
|
||||
// Typ für Gruppe ergänzen
|
||||
type Group = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
// Typ für Event ergänzen
|
||||
type Event = {
|
||||
Id: string;
|
||||
Subject: string;
|
||||
StartTime: Date;
|
||||
EndTime: Date;
|
||||
IsAllDay: boolean;
|
||||
IsBlock?: boolean; // Syncfusion block appointment
|
||||
isHoliday?: boolean; // marker for styling/logic
|
||||
MediaId?: string | number;
|
||||
SlideshowInterval?: number;
|
||||
WebsiteUrl?: string;
|
||||
Icon?: string; // <--- Icon ergänzen!
|
||||
Type?: string; // <--- Typ ergänzen, falls benötigt
|
||||
};
|
||||
|
||||
type RawEvent = {
|
||||
Id: string;
|
||||
Subject: string;
|
||||
StartTime: string;
|
||||
EndTime: string;
|
||||
IsAllDay: boolean;
|
||||
MediaId?: string | number;
|
||||
Icon?: string; // <--- Icon ergänzen!
|
||||
Type?: string;
|
||||
};
|
||||
|
||||
// CLDR-Daten laden (direkt die JSON-Objekte übergeben)
|
||||
loadCldr(
|
||||
caGregorian as object,
|
||||
numbers as object,
|
||||
timeZoneNames as object,
|
||||
numberingSystems as object
|
||||
);
|
||||
|
||||
// Deutsche Lokalisierung für den Scheduler
|
||||
L10n.load({
|
||||
de: {
|
||||
schedule: {
|
||||
day: 'Tag',
|
||||
week: 'Woche',
|
||||
workWeek: 'Arbeitswoche',
|
||||
month: 'Monat',
|
||||
agenda: 'Agenda',
|
||||
today: 'Heute',
|
||||
noEvents: 'Keine Termine',
|
||||
allDay: 'Ganztägig',
|
||||
start: 'Start',
|
||||
end: 'Ende',
|
||||
event: 'Termin',
|
||||
save: 'Speichern',
|
||||
cancel: 'Abbrechen',
|
||||
delete: 'Löschen',
|
||||
edit: 'Bearbeiten',
|
||||
newEvent: 'Neuer Termin',
|
||||
title: 'Titel',
|
||||
description: 'Beschreibung',
|
||||
location: 'Ort',
|
||||
recurrence: 'Wiederholung',
|
||||
repeat: 'Wiederholen',
|
||||
deleteEvent: 'Termin löschen',
|
||||
deleteContent: 'Möchten Sie diesen Termin wirklich löschen?',
|
||||
moreDetails: 'Mehr Details',
|
||||
addTitle: 'Termintitel',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Kultur setzen
|
||||
setCulture('de');
|
||||
|
||||
// Mapping für Lucide-Icons
|
||||
const iconMap: Record<string, React.ElementType> = {
|
||||
Presentation,
|
||||
Globe,
|
||||
Video,
|
||||
MessageSquare,
|
||||
School,
|
||||
};
|
||||
|
||||
const eventTemplate = (event: Event) => {
|
||||
const IconComponent = iconMap[event.Icon ?? ''] || null;
|
||||
// Zeitangabe formatieren
|
||||
const start = event.StartTime instanceof Date ? event.StartTime : new Date(event.StartTime);
|
||||
const end = event.EndTime instanceof Date ? event.EndTime : new Date(event.EndTime);
|
||||
const timeString = `${start.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${end.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', color: '#000', marginBottom: 2 }}>
|
||||
{IconComponent && (
|
||||
<span style={{ verticalAlign: 'middle', display: 'inline-block', marginRight: 6 }}>
|
||||
<IconComponent size={18} color="#000" />
|
||||
</span>
|
||||
)}
|
||||
<span style={{ marginTop: 3 }}>{event.Subject}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.95em', color: '#000', marginTop: -2 }}>{timeString}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Appointments: React.FC = () => {
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [holidays, setHolidays] = useState<Holiday[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalInitialData, setModalInitialData] = useState({});
|
||||
const [schedulerKey, setSchedulerKey] = useState(0);
|
||||
const [editMode, setEditMode] = useState(false); // NEU: Editiermodus
|
||||
const [showInactive, setShowInactive] = React.useState(true);
|
||||
const [allowScheduleOnHolidays, setAllowScheduleOnHolidays] = React.useState(false);
|
||||
const [showHolidayList, setShowHolidayList] = React.useState(true);
|
||||
const scheduleRef = React.useRef<ScheduleComponent | null>(null);
|
||||
const [holidaysInView, setHolidaysInView] = React.useState<number>(0);
|
||||
const [schoolYearLabel, setSchoolYearLabel] = React.useState<string>('');
|
||||
const [hasSchoolYearPlan, setHasSchoolYearPlan] = React.useState<boolean>(false);
|
||||
const [periods, setPeriods] = React.useState<{ id: number; label: string }[]>([]);
|
||||
const [activePeriodId, setActivePeriodId] = React.useState<number | null>(null);
|
||||
|
||||
// Gruppen laden
|
||||
useEffect(() => {
|
||||
fetchGroups()
|
||||
.then(data => {
|
||||
// Nur Gruppen mit id != 1 berücksichtigen (nicht zugeordnet ignorieren)
|
||||
const filtered = Array.isArray(data) ? data.filter(g => g.id && g.name && g.id !== 1) : [];
|
||||
setGroups(filtered);
|
||||
if (filtered.length > 0) setSelectedGroupId(filtered[0].id);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Holidays laden
|
||||
useEffect(() => {
|
||||
listHolidays()
|
||||
.then(res => setHolidays(res.holidays || []))
|
||||
.catch(err => console.error('Ferien laden fehlgeschlagen:', err));
|
||||
}, []);
|
||||
|
||||
// Perioden laden (Dropdown)
|
||||
useEffect(() => {
|
||||
listAcademicPeriods()
|
||||
.then(all => {
|
||||
setPeriods(all.map(p => ({ id: p.id, label: p.display_name || p.name })));
|
||||
const active = all.find(p => p.is_active);
|
||||
setActivePeriodId(active ? active.id : null);
|
||||
})
|
||||
.catch(err => console.error('Akademische Perioden laden fehlgeschlagen:', err));
|
||||
}, []);
|
||||
|
||||
// fetchAndSetEvents als useCallback definieren, damit die Dependency korrekt ist:
|
||||
const fetchAndSetEvents = React.useCallback(async () => {
|
||||
if (!selectedGroupId) {
|
||||
setEvents([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await fetchEvents(selectedGroupId, showInactive); // selectedGroupId ist jetzt garantiert string
|
||||
const mapped: Event[] = data.map((e: RawEvent) => ({
|
||||
Id: e.Id,
|
||||
Subject: e.Subject,
|
||||
StartTime: new Date(e.StartTime.endsWith('Z') ? e.StartTime : e.StartTime + 'Z'),
|
||||
EndTime: new Date(e.EndTime.endsWith('Z') ? e.EndTime : e.EndTime + 'Z'),
|
||||
IsAllDay: e.IsAllDay,
|
||||
MediaId: e.MediaId,
|
||||
Icon: e.Icon, // <--- Icon übernehmen!
|
||||
Type: e.Type, // <--- Typ übernehmen!
|
||||
}));
|
||||
setEvents(mapped);
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden der Termine:', err);
|
||||
}
|
||||
}, [selectedGroupId, showInactive]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedGroupId) {
|
||||
// selectedGroupId kann null sein, fetchEvents erwartet aber string
|
||||
fetchAndSetEvents();
|
||||
} else {
|
||||
setEvents([]);
|
||||
}
|
||||
}, [selectedGroupId, showInactive, fetchAndSetEvents]);
|
||||
|
||||
// Helper: prüfe, ob Zeitraum einen Feiertag/Ferienbereich schneidet
|
||||
const isWithinHolidayRange = React.useCallback(
|
||||
(start: Date, end: Date) => {
|
||||
// normalisiere Endzeit minimal (Syncfusion nutzt exklusive Enden in einigen Fällen)
|
||||
const adjEnd = new Date(end);
|
||||
// keine Änderung nötig – unsere eigenen Events sind präzise
|
||||
for (const h of holidays) {
|
||||
// Holiday dates are strings YYYY-MM-DD (local date)
|
||||
const hs = new Date(h.start_date + 'T00:00:00');
|
||||
const he = new Date(h.end_date + 'T23:59:59');
|
||||
if (
|
||||
(start >= hs && start <= he) ||
|
||||
(adjEnd >= hs && adjEnd <= he) ||
|
||||
(start <= hs && adjEnd >= he)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[holidays]
|
||||
);
|
||||
|
||||
// Baue Holiday-Anzeige-Events und Block-Events
|
||||
const holidayDisplayEvents: Event[] = useMemo(() => {
|
||||
if (!showHolidayList) return [];
|
||||
const out: Event[] = [];
|
||||
for (const h of holidays) {
|
||||
const start = new Date(h.start_date + 'T00:00:00');
|
||||
const end = new Date(h.end_date + 'T23:59:59');
|
||||
out.push({
|
||||
Id: `holiday-${h.id}-display`,
|
||||
Subject: h.name,
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
IsAllDay: true,
|
||||
isHoliday: true,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [holidays, showHolidayList]);
|
||||
|
||||
const holidayBlockEvents: Event[] = useMemo(() => {
|
||||
if (allowScheduleOnHolidays) return [];
|
||||
const out: Event[] = [];
|
||||
for (const h of holidays) {
|
||||
const start = new Date(h.start_date + 'T00:00:00');
|
||||
const end = new Date(h.end_date + 'T23:59:59');
|
||||
out.push({
|
||||
Id: `holiday-${h.id}-block`,
|
||||
Subject: h.name,
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
IsAllDay: true,
|
||||
IsBlock: true,
|
||||
isHoliday: true,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [holidays, allowScheduleOnHolidays]);
|
||||
|
||||
const dataSource = useMemo(() => {
|
||||
return [...events, ...holidayDisplayEvents, ...holidayBlockEvents];
|
||||
}, [events, holidayDisplayEvents, holidayBlockEvents]);
|
||||
|
||||
// Aktive akademische Periode für Datum aus dem Backend ermitteln
|
||||
const refreshAcademicPeriodFor = React.useCallback(
|
||||
async (baseDate: Date) => {
|
||||
try {
|
||||
const p = await getAcademicPeriodForDate(baseDate);
|
||||
if (!p) {
|
||||
setSchoolYearLabel('');
|
||||
setHasSchoolYearPlan(false);
|
||||
return;
|
||||
}
|
||||
// Anzeige: bevorzugt display_name, sonst name
|
||||
const label = p.display_name ? p.display_name : p.name;
|
||||
setSchoolYearLabel(label);
|
||||
// Existiert ein Ferienplan innerhalb der Periode?
|
||||
const start = new Date(p.start_date + 'T00:00:00');
|
||||
const end = new Date(p.end_date + 'T23:59:59');
|
||||
let exists = false;
|
||||
for (const h of holidays) {
|
||||
const hs = new Date(h.start_date + 'T00:00:00');
|
||||
const he = new Date(h.end_date + 'T23:59:59');
|
||||
if (hs <= end && he >= start) {
|
||||
exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setHasSchoolYearPlan(exists);
|
||||
} catch (e) {
|
||||
console.error('Akademische Periode laden fehlgeschlagen:', e);
|
||||
setSchoolYearLabel('');
|
||||
setHasSchoolYearPlan(false);
|
||||
}
|
||||
},
|
||||
[holidays]
|
||||
);
|
||||
|
||||
// Anzahl an Ferienzeiträumen in aktueller Ansicht ermitteln + Perioden-Indikator setzen
|
||||
const updateHolidaysInView = React.useCallback(() => {
|
||||
const inst = scheduleRef.current;
|
||||
if (!inst) {
|
||||
setHolidaysInView(0);
|
||||
return;
|
||||
}
|
||||
const view = inst.currentView as 'Day' | 'Week' | 'WorkWeek' | 'Month' | 'Agenda';
|
||||
const baseDate = inst.selectedDate as Date;
|
||||
if (!baseDate) {
|
||||
setHolidaysInView(0);
|
||||
return;
|
||||
}
|
||||
let rangeStart = new Date(baseDate);
|
||||
let rangeEnd = new Date(baseDate);
|
||||
if (view === 'Day' || view === 'Agenda') {
|
||||
rangeStart.setHours(0, 0, 0, 0);
|
||||
rangeEnd.setHours(23, 59, 59, 999);
|
||||
} else if (view === 'Week' || view === 'WorkWeek') {
|
||||
const day = baseDate.getDay();
|
||||
const diffToMonday = (day + 6) % 7; // Monday=0
|
||||
rangeStart = new Date(baseDate);
|
||||
rangeStart.setDate(baseDate.getDate() - diffToMonday);
|
||||
rangeStart.setHours(0, 0, 0, 0);
|
||||
rangeEnd = new Date(rangeStart);
|
||||
rangeEnd.setDate(rangeStart.getDate() + 6);
|
||||
rangeEnd.setHours(23, 59, 59, 999);
|
||||
} else if (view === 'Month') {
|
||||
rangeStart = new Date(baseDate.getFullYear(), baseDate.getMonth(), 1, 0, 0, 0, 0);
|
||||
rangeEnd = new Date(baseDate.getFullYear(), baseDate.getMonth() + 1, 0, 23, 59, 59, 999);
|
||||
}
|
||||
let count = 0;
|
||||
for (const h of holidays) {
|
||||
const hs = new Date(h.start_date + 'T00:00:00');
|
||||
const he = new Date(h.end_date + 'T23:59:59');
|
||||
const overlaps =
|
||||
(hs >= rangeStart && hs <= rangeEnd) ||
|
||||
(he >= rangeStart && he <= rangeEnd) ||
|
||||
(hs <= rangeStart && he >= rangeEnd);
|
||||
if (overlaps) count += 1;
|
||||
}
|
||||
setHolidaysInView(count);
|
||||
// Perioden-Indikator über Backend prüfen
|
||||
refreshAcademicPeriodFor(baseDate);
|
||||
}, [holidays, refreshAcademicPeriodFor]);
|
||||
|
||||
// Aktualisiere Indikator wenn Ferien oder Ansicht (Key) wechseln
|
||||
React.useEffect(() => {
|
||||
updateHolidaysInView();
|
||||
}, [holidays, updateHolidaysInView, schedulerKey]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Terminmanagement</h1>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<label htmlFor="groupDropdown" className="mb-0 mr-2" style={{ whiteSpace: 'nowrap' }}>
|
||||
Raumgruppe auswählen:
|
||||
</label>
|
||||
<DropDownListComponent
|
||||
id="groupDropdown"
|
||||
dataSource={groups}
|
||||
fields={{ text: 'name', value: 'id' }}
|
||||
placeholder="Gruppe auswählen"
|
||||
value={selectedGroupId}
|
||||
width="240px"
|
||||
change={(e: { value: string }) => {
|
||||
// <--- Typ für e ergänzt
|
||||
setEvents([]); // Events sofort leeren
|
||||
setSelectedGroupId(e.value);
|
||||
}}
|
||||
style={{}}
|
||||
/>
|
||||
|
||||
{/* Akademische Periode Selector + Plan-Badge */}
|
||||
<span style={{ marginLeft: 8, whiteSpace: 'nowrap' }}>Periode:</span>
|
||||
<DropDownListComponent
|
||||
id="periodDropdown"
|
||||
dataSource={periods}
|
||||
fields={{ text: 'label', value: 'id' }}
|
||||
placeholder="Periode wählen"
|
||||
value={activePeriodId ?? undefined}
|
||||
width="260px"
|
||||
change={async (e: { value: number }) => {
|
||||
const id = Number(e.value);
|
||||
if (!id) return;
|
||||
try {
|
||||
const updated = await setActiveAcademicPeriod(id);
|
||||
setActivePeriodId(updated.id);
|
||||
// Zum gleichen Tag/Monat (heute) innerhalb der gewählten Periode springen
|
||||
const today = new Date();
|
||||
const targetYear = new Date(updated.start_date).getFullYear();
|
||||
const target = new Date(targetYear, today.getMonth(), today.getDate(), 12, 0, 0);
|
||||
if (scheduleRef.current) {
|
||||
scheduleRef.current.selectedDate = target;
|
||||
scheduleRef.current.dataBind?.();
|
||||
}
|
||||
updateHolidaysInView();
|
||||
} catch (err) {
|
||||
console.error('Aktive Periode setzen fehlgeschlagen:', err);
|
||||
}
|
||||
}}
|
||||
style={{}}
|
||||
/>
|
||||
{/* School-year/period plan badge (adjacent) */}
|
||||
<span
|
||||
title={hasSchoolYearPlan ? 'Ferienplan ist hinterlegt' : 'Kein Ferienplan hinterlegt'}
|
||||
style={{
|
||||
background: hasSchoolYearPlan ? '#dcfce7' : '#f3f4f6',
|
||||
border: hasSchoolYearPlan ? '1px solid #86efac' : '1px solid #e5e7eb',
|
||||
color: '#000',
|
||||
padding: '4px 10px',
|
||||
borderRadius: 16,
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{hasSchoolYearPlan ? (
|
||||
<CheckCircle size={14} color="#166534" />
|
||||
) : (
|
||||
<AlertCircle size={14} color="#6b7280" />
|
||||
)}
|
||||
{schoolYearLabel || 'Periode'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="e-btn e-success mb-4"
|
||||
onClick={() => {
|
||||
const now = new Date();
|
||||
// Runde auf die nächste halbe Stunde
|
||||
const minutes = now.getMinutes();
|
||||
const roundedMinutes = minutes < 30 ? 30 : 0;
|
||||
const startTime = new Date(now);
|
||||
startTime.setMinutes(roundedMinutes, 0, 0);
|
||||
if (roundedMinutes === 0) startTime.setHours(startTime.getHours() + 1);
|
||||
|
||||
const endTime = new Date(startTime);
|
||||
endTime.setMinutes(endTime.getMinutes() + 30);
|
||||
|
||||
setModalInitialData({
|
||||
startDate: startTime,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
});
|
||||
setEditMode(false);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Neuen Termin anlegen
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 24,
|
||||
marginBottom: 16,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showInactive}
|
||||
onChange={e => setShowInactive(e.target.checked)}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
Vergangene Termine anzeigen
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowScheduleOnHolidays}
|
||||
onChange={e => setAllowScheduleOnHolidays(e.target.checked)}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
Termine an Ferientagen erlauben
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showHolidayList}
|
||||
onChange={e => setShowHolidayList(e.target.checked)}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
Ferien im Kalender anzeigen
|
||||
</label>
|
||||
{/* Right-aligned indicators */}
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
{/* Holidays-in-view badge */}
|
||||
<span
|
||||
title="Anzahl der Ferientage/-zeiträume in der aktuellen Ansicht"
|
||||
style={{
|
||||
background: holidaysInView > 0 ? '#ffe8cc' : '#f3f4f6',
|
||||
border: holidaysInView > 0 ? '1px solid #ffcf99' : '1px solid #e5e7eb',
|
||||
color: '#000',
|
||||
padding: '4px 10px',
|
||||
borderRadius: 16,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{holidaysInView > 0 ? `Ferien im Blick: ${holidaysInView}` : 'Keine Ferien in Ansicht'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<CustomEventModal
|
||||
open={modalOpen}
|
||||
onClose={() => {
|
||||
setModalOpen(false);
|
||||
setEditMode(false); // Editiermodus zurücksetzen
|
||||
}}
|
||||
onSave={async () => {
|
||||
setModalOpen(false);
|
||||
setEditMode(false);
|
||||
if (selectedGroupId) {
|
||||
const data = await fetchEvents(selectedGroupId, showInactive);
|
||||
const mapped: Event[] = data.map((e: RawEvent) => ({
|
||||
Id: e.Id,
|
||||
Subject: e.Subject,
|
||||
StartTime: new Date(e.StartTime.endsWith('Z') ? e.StartTime : e.StartTime + 'Z'),
|
||||
EndTime: new Date(e.EndTime.endsWith('Z') ? e.EndTime : e.EndTime + 'Z'),
|
||||
IsAllDay: e.IsAllDay,
|
||||
MediaId: e.MediaId,
|
||||
}));
|
||||
setEvents(mapped);
|
||||
setSchedulerKey(prev => prev + 1); // <-- Key erhöhen
|
||||
}
|
||||
}}
|
||||
initialData={modalInitialData}
|
||||
groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }}
|
||||
groupColor={selectedGroupId ? getGroupColor(selectedGroupId, groups) : undefined}
|
||||
editMode={editMode} // NEU: Prop für Editiermodus
|
||||
blockHolidays={!allowScheduleOnHolidays}
|
||||
isHolidayRange={(s, e) => isWithinHolidayRange(s, e)}
|
||||
/>
|
||||
<ScheduleComponent
|
||||
ref={scheduleRef}
|
||||
key={schedulerKey} // <-- dynamischer Key
|
||||
height="750px"
|
||||
locale="de"
|
||||
currentView="Week"
|
||||
eventSettings={{
|
||||
dataSource: dataSource,
|
||||
fields: { isBlock: 'IsBlock' },
|
||||
template: eventTemplate, // <--- Hier das Template setzen!
|
||||
}}
|
||||
actionComplete={() => updateHolidaysInView()}
|
||||
cellClick={args => {
|
||||
if (!allowScheduleOnHolidays && isWithinHolidayRange(args.startTime, args.endTime)) {
|
||||
args.cancel = true;
|
||||
return; // block creation on holidays
|
||||
}
|
||||
// args.startTime und args.endTime sind Date-Objekte
|
||||
args.cancel = true; // Verhindert die Standardaktion
|
||||
setModalInitialData({
|
||||
startDate: args.startTime,
|
||||
startTime: args.startTime,
|
||||
endTime: args.endTime,
|
||||
});
|
||||
setEditMode(false); // NEU: kein Editiermodus
|
||||
setModalOpen(true);
|
||||
}}
|
||||
popupOpen={async args => {
|
||||
if (args.type === 'Editor') {
|
||||
args.cancel = true;
|
||||
const event = args.data;
|
||||
console.log('Event zum Bearbeiten:', event);
|
||||
let media = null;
|
||||
if (event.MediaId) {
|
||||
try {
|
||||
const mediaData = await fetchMediaById(event.MediaId);
|
||||
media = {
|
||||
id: mediaData.id,
|
||||
path: mediaData.file_path,
|
||||
name: mediaData.name || mediaData.url,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden der Mediainfos:', err);
|
||||
}
|
||||
}
|
||||
setModalInitialData({
|
||||
Id: event.Id,
|
||||
title: event.Subject,
|
||||
startDate: event.StartTime,
|
||||
startTime: event.StartTime,
|
||||
endTime: event.EndTime,
|
||||
description: event.Description ?? '',
|
||||
type: event.Type ?? 'presentation',
|
||||
repeat: event.Repeat ?? false,
|
||||
weekdays: event.Weekdays ?? [],
|
||||
repeatUntil: event.RepeatUntil ?? null,
|
||||
skipHolidays: event.SkipHolidays ?? false,
|
||||
media, // Metadaten werden nur bei Bedarf geladen!
|
||||
slideshowInterval: event.SlideshowInterval ?? 10,
|
||||
websiteUrl: event.WebsiteUrl ?? '',
|
||||
});
|
||||
console.log('Modal initial data:', {
|
||||
Id: event.Id,
|
||||
title: event.Subject,
|
||||
startDate: event.StartTime,
|
||||
startTime: event.StartTime,
|
||||
endTime: event.EndTime,
|
||||
description: event.Description ?? '',
|
||||
type: event.Type ?? 'presentation',
|
||||
repeat: event.Repeat ?? false,
|
||||
weekdays: event.Weekdays ?? [],
|
||||
repeatUntil: event.RepeatUntil ?? null,
|
||||
skipHolidays: event.SkipHolidays ?? false,
|
||||
media, // Metadaten werden nur bei Bedarf geladen!
|
||||
slideshowInterval: event.SlideshowInterval ?? 10,
|
||||
websiteUrl: event.WebsiteUrl ?? '',
|
||||
});
|
||||
setEditMode(true);
|
||||
setModalOpen(true);
|
||||
}
|
||||
}}
|
||||
eventRendered={(args: EventRenderedArgs) => {
|
||||
// Blende Nicht-Ferien-Events aus, falls sie in Ferien fallen und Terminieren nicht erlaubt ist
|
||||
if (!allowScheduleOnHolidays && args.data && !args.data.isHoliday) {
|
||||
const s =
|
||||
args.data.StartTime instanceof Date
|
||||
? args.data.StartTime
|
||||
: new Date(args.data.StartTime);
|
||||
const e =
|
||||
args.data.EndTime instanceof Date ? args.data.EndTime : new Date(args.data.EndTime);
|
||||
if (isWithinHolidayRange(s, e)) {
|
||||
args.cancel = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedGroupId && args.data && args.data.Id) {
|
||||
const groupColor = getGroupColor(selectedGroupId, groups);
|
||||
const now = new Date();
|
||||
|
||||
let IconComponent: React.ElementType | null = null;
|
||||
switch (args.data.Type) {
|
||||
case 'presentation':
|
||||
IconComponent = Presentation;
|
||||
break;
|
||||
case 'website':
|
||||
IconComponent = Globe;
|
||||
break;
|
||||
case 'video':
|
||||
IconComponent = Video;
|
||||
break;
|
||||
case 'message':
|
||||
IconComponent = MessageSquare;
|
||||
break;
|
||||
case 'webuntis':
|
||||
IconComponent = School;
|
||||
break;
|
||||
default:
|
||||
IconComponent = null;
|
||||
}
|
||||
|
||||
// Nur .e-subject verwenden!
|
||||
const titleElement = args.element.querySelector('.e-subject');
|
||||
if (titleElement && IconComponent) {
|
||||
const svgString = renderToStaticMarkup(<IconComponent size={18} color="#78591c" />);
|
||||
// Immer nur den reinen Text nehmen, kein vorhandenes Icon!
|
||||
const subjectText = (titleElement as HTMLElement).textContent ?? '';
|
||||
(titleElement as HTMLElement).innerHTML =
|
||||
`<span style="vertical-align:middle;display:inline-block;margin-right:6px;">${svgString}</span>` +
|
||||
subjectText;
|
||||
}
|
||||
|
||||
// Vergangene Termine: Raumgruppenfarbe
|
||||
if (args.data.EndTime && args.data.EndTime < now) {
|
||||
args.element.style.backgroundColor = groupColor ? `${groupColor}` : '#f3f3f3';
|
||||
args.element.style.color = '#000';
|
||||
} else if (groupColor) {
|
||||
args.element.style.backgroundColor = groupColor;
|
||||
args.element.style.color = '#000';
|
||||
}
|
||||
|
||||
// Spezielle Darstellung für Ferienanzeige-Events
|
||||
if (args.data.isHoliday && !args.data.IsBlock) {
|
||||
args.element.style.backgroundColor = '#ffe8cc'; // sanftes Orange
|
||||
args.element.style.border = '1px solid #ffcf99';
|
||||
args.element.style.color = '#000';
|
||||
}
|
||||
// Gleiche Darstellung für Ferien-Block-Events
|
||||
if (args.data.isHoliday && args.data.IsBlock) {
|
||||
args.element.style.backgroundColor = '#ffe8cc';
|
||||
args.element.style.border = '1px solid #ffcf99';
|
||||
args.element.style.color = '#000';
|
||||
}
|
||||
}
|
||||
}}
|
||||
actionBegin={async (args: ActionEventArgs) => {
|
||||
if (args.requestType === 'eventRemove') {
|
||||
// args.data ist ein Array von zu löschenden Events
|
||||
const toDelete = Array.isArray(args.data) ? args.data : [args.data];
|
||||
for (const ev of toDelete) {
|
||||
try {
|
||||
await deleteEvent(ev.Id); // Deine API-Funktion
|
||||
} catch (err) {
|
||||
// Optional: Fehlerbehandlung
|
||||
console.error('Fehler beim Löschen:', err);
|
||||
}
|
||||
}
|
||||
// Events nach Löschen neu laden
|
||||
if (selectedGroupId) {
|
||||
fetchEvents(selectedGroupId, showInactive)
|
||||
.then((data: RawEvent[]) => {
|
||||
const mapped: Event[] = data.map((e: RawEvent) => ({
|
||||
Id: e.Id,
|
||||
Subject: e.Subject,
|
||||
StartTime: new Date(e.StartTime),
|
||||
EndTime: new Date(e.EndTime),
|
||||
IsAllDay: e.IsAllDay,
|
||||
MediaId: e.MediaId,
|
||||
}));
|
||||
setEvents(mapped);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
// Syncfusion soll das Event nicht selbst löschen
|
||||
args.cancel = true;
|
||||
} else if (
|
||||
(args.requestType === 'eventCreate' || args.requestType === 'eventChange') &&
|
||||
!allowScheduleOnHolidays
|
||||
) {
|
||||
// Verhindere Erstellen/Ändern in Ferienbereichen (Failsafe, falls eingebauter Editor genutzt wird)
|
||||
type PartialEventLike = { StartTime?: Date | string; EndTime?: Date | string };
|
||||
const raw = (args as ActionEventArgs).data as
|
||||
| PartialEventLike
|
||||
| PartialEventLike[]
|
||||
| undefined;
|
||||
const data = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (data && data.StartTime && data.EndTime) {
|
||||
const s = data.StartTime instanceof Date ? data.StartTime : new Date(data.StartTime);
|
||||
const e = data.EndTime instanceof Date ? data.EndTime : new Date(data.EndTime);
|
||||
if (isWithinHolidayRange(s, e)) {
|
||||
args.cancel = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
firstDayOfWeek={1}
|
||||
renderCell={(args: RenderCellEventArgs) => {
|
||||
// Nur für Arbeitszellen (Stunden-/Tageszellen)
|
||||
if (args.elementType === 'workCells') {
|
||||
const now = new Date();
|
||||
// args.element ist vom Typ Element, daher als HTMLElement casten:
|
||||
const cell = args.element as HTMLElement;
|
||||
if (args.date && args.date < now) {
|
||||
cell.style.backgroundColor = '#fff9e3'; // Hellgelb für Vergangenheit
|
||||
cell.style.opacity = '0.7';
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ViewsDirective>
|
||||
<ViewDirective option="Day" />
|
||||
<ViewDirective option="Week" />
|
||||
<ViewDirective option="WorkWeek" />
|
||||
<ViewDirective option="Month" />
|
||||
<ViewDirective option="Agenda" />
|
||||
</ViewsDirective>
|
||||
<Inject services={[Day, Week, WorkWeek, Month, Agenda]} />
|
||||
</ScheduleComponent>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Appointments;
|
||||
BIN
dashboard/src/assets/TAA_Logo.png
Normal file
BIN
dashboard/src/assets/TAA_Logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
dashboard/src/assets/logo.png
Normal file
BIN
dashboard/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 225 KiB |
1
dashboard/src/assets/react.svg
Normal file
1
dashboard/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
8
dashboard/src/benutzer.tsx
Normal file
8
dashboard/src/benutzer.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
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
278
dashboard/src/clients.tsx
Normal file
278
dashboard/src/clients.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import SetupModeButton from './components/SetupModeButton';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useClientDelete } from './hooks/useClientDelete';
|
||||
import { fetchClients, updateClient } from './apiClients';
|
||||
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';
|
||||
|
||||
// Raumgruppen werden dynamisch aus der API geladen
|
||||
|
||||
interface DetailsModalProps {
|
||||
open: boolean;
|
||||
client: Client | null;
|
||||
groupIdToName: Record<string | number, string>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function DetailsModal({ open, client, groupIdToName, onClose }: DetailsModalProps) {
|
||||
if (!open || !client) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.3)',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'white',
|
||||
padding: 0,
|
||||
margin: '100px auto',
|
||||
maxWidth: 500,
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 4px 24px rgba(0,0,0,0.12)',
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: 32 }}>
|
||||
<h3 style={{ fontSize: '1.25rem', fontWeight: 700, marginBottom: 18 }}>Client-Details</h3>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: 24 }}>
|
||||
<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: 'bold', padding: '6px 8px' }}>
|
||||
{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] || value
|
||||
: ''
|
||||
: key === 'registration_time' && value
|
||||
? new Date(
|
||||
(value as string).endsWith('Z') ? (value as string) : value + 'Z'
|
||||
).toLocaleString()
|
||||
: key === 'last_alive' && value
|
||||
? String(value) // Wert direkt anzeigen, nicht erneut parsen
|
||||
: String(value)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<button className="e-btn e-outline" onClick={onClose}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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: '8px' }}>
|
||||
<button
|
||||
className="e-btn e-primary"
|
||||
onClick={() => setDetailsClient(props)}
|
||||
style={{ minWidth: 80 }}
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
<button
|
||||
className="e-btn e-danger"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleDelete(props.uuid);
|
||||
}}
|
||||
style={{ minWidth: 80 }}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold">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={400}
|
||||
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="80" />
|
||||
<ColumnDirective
|
||||
field="last_alive"
|
||||
headerText="Last Alive"
|
||||
allowEditing={false}
|
||||
width="120"
|
||||
/>
|
||||
<ColumnDirective field="model" headerText="Model" allowEditing={true} width="120" />
|
||||
<ColumnDirective
|
||||
headerText="Aktion"
|
||||
width="190"
|
||||
template={detailsButtonTemplate}
|
||||
textAlign="Center"
|
||||
allowEditing={false}
|
||||
/>
|
||||
</ColumnsDirective>
|
||||
<Inject services={[Page, Toolbar, Search, Sort, Edit]} />
|
||||
</GridComponent>
|
||||
<DetailsModal
|
||||
open={!!detailsClient}
|
||||
client={detailsClient}
|
||||
groupIdToName={groupIdToName}
|
||||
onClose={() => setDetailsClient(null)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-gray-500">Raumgruppen werden geladen ...</div>
|
||||
)}
|
||||
{/* DialogComponent für Bestätigung */}
|
||||
{showDialog && deleteClientId && (
|
||||
<DialogComponent
|
||||
visible={showDialog}
|
||||
header="Bestätigung"
|
||||
content="Möchten Sie diesen Client wirklich entfernen?"
|
||||
showCloseIcon={true}
|
||||
width="400px"
|
||||
buttons={[
|
||||
{ click: confirmDelete, buttonModel: { content: 'Ja', isPrimary: true } },
|
||||
{ click: cancelDelete, buttonModel: { content: 'Abbrechen' } },
|
||||
]}
|
||||
close={cancelDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Clients;
|
||||
515
dashboard/src/components/CustomEventModal.tsx
Normal file
515
dashboard/src/components/CustomEventModal.tsx
Normal file
@@ -0,0 +1,515 @@
|
||||
import React from 'react';
|
||||
import { DialogComponent } from '@syncfusion/ej2-react-popups';
|
||||
import { TextBoxComponent } from '@syncfusion/ej2-react-inputs';
|
||||
import { DatePickerComponent, TimePickerComponent } from '@syncfusion/ej2-react-calendars';
|
||||
import { DropDownListComponent, MultiSelectComponent } from '@syncfusion/ej2-react-dropdowns';
|
||||
import { CheckBoxComponent } from '@syncfusion/ej2-react-buttons';
|
||||
import CustomSelectUploadEventModal from './CustomSelectUploadEventModal';
|
||||
import { updateEvent } from '../apiEvents';
|
||||
|
||||
type CustomEventData = {
|
||||
title: string;
|
||||
startDate: Date | null;
|
||||
startTime: Date | null;
|
||||
endTime: Date | null;
|
||||
type: string;
|
||||
description: string;
|
||||
repeat: boolean;
|
||||
weekdays: number[];
|
||||
repeatUntil: Date | null;
|
||||
skipHolidays: boolean;
|
||||
media?: { id: string; path: string; name: string } | null; // <--- ergänzt
|
||||
slideshowInterval?: number; // <--- ergänzt
|
||||
websiteUrl?: string; // <--- ergänzt
|
||||
};
|
||||
|
||||
// Typ für initialData erweitern, damit Id unterstützt wird
|
||||
type CustomEventModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (eventData: CustomEventData) => void;
|
||||
initialData?: Partial<CustomEventData> & { Id?: string }; // <--- Id ergänzen
|
||||
groupName: string | { id: string | null; name: string };
|
||||
groupColor?: string;
|
||||
editMode?: boolean;
|
||||
blockHolidays?: boolean;
|
||||
isHolidayRange?: (start: Date, end: Date) => boolean;
|
||||
};
|
||||
|
||||
const weekdayOptions = [
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 'presentation', label: 'Präsentation' },
|
||||
{ value: 'website', label: 'Website' },
|
||||
{ value: 'video', label: 'Video' },
|
||||
{ value: 'message', label: 'Nachricht' },
|
||||
{ value: 'webuntis', label: 'WebUntis' },
|
||||
];
|
||||
|
||||
const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
initialData = {},
|
||||
groupName,
|
||||
groupColor,
|
||||
editMode,
|
||||
blockHolidays,
|
||||
isHolidayRange,
|
||||
}) => {
|
||||
const [title, setTitle] = React.useState(initialData.title || '');
|
||||
const [startDate, setStartDate] = React.useState(initialData.startDate || null);
|
||||
const [startTime, setStartTime] = React.useState(
|
||||
initialData.startTime || new Date(0, 0, 0, 9, 0)
|
||||
);
|
||||
const [endTime, setEndTime] = React.useState(initialData.endTime || new Date(0, 0, 0, 9, 30));
|
||||
const [type, setType] = React.useState(initialData.type ?? 'presentation');
|
||||
const [description, setDescription] = React.useState(initialData.description || '');
|
||||
const [repeat, setRepeat] = React.useState(initialData.repeat || false);
|
||||
const [weekdays, setWeekdays] = React.useState<number[]>(initialData.weekdays || []);
|
||||
const [repeatUntil, setRepeatUntil] = React.useState(initialData.repeatUntil || null);
|
||||
const [skipHolidays, setSkipHolidays] = React.useState(initialData.skipHolidays || false);
|
||||
const [errors, setErrors] = React.useState<{ [key: string]: string }>({});
|
||||
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
|
||||
const [media, setMedia] = React.useState<{ id: string; path: string; name: string } | null>(
|
||||
initialData.media ?? null
|
||||
);
|
||||
const [pendingMedia, setPendingMedia] = React.useState<{
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const [slideshowInterval, setSlideshowInterval] = React.useState<number>(
|
||||
initialData.slideshowInterval ?? 10
|
||||
);
|
||||
const [websiteUrl, setWebsiteUrl] = React.useState<string>(initialData.websiteUrl ?? '');
|
||||
const [mediaModalOpen, setMediaModalOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setTitle(initialData.title || '');
|
||||
setStartDate(initialData.startDate || null);
|
||||
setStartTime(initialData.startTime || new Date(0, 0, 0, 9, 0));
|
||||
setEndTime(initialData.endTime || new Date(0, 0, 0, 9, 30));
|
||||
setType(initialData.type ?? 'presentation');
|
||||
setDescription(initialData.description || '');
|
||||
setRepeat(initialData.repeat || false);
|
||||
setWeekdays(initialData.weekdays || []);
|
||||
setRepeatUntil(initialData.repeatUntil || null);
|
||||
setSkipHolidays(initialData.skipHolidays || false);
|
||||
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
|
||||
setMedia(initialData.media ?? null);
|
||||
setSlideshowInterval(initialData.slideshowInterval ?? 10);
|
||||
setWebsiteUrl(initialData.websiteUrl ?? '');
|
||||
}
|
||||
}, [open, initialData]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!mediaModalOpen && pendingMedia) {
|
||||
setMedia(pendingMedia);
|
||||
setPendingMedia(null);
|
||||
}
|
||||
}, [mediaModalOpen, pendingMedia]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const newErrors: { [key: string]: string } = {};
|
||||
if (!title.trim()) newErrors.title = 'Titel ist erforderlich';
|
||||
if (!startDate) newErrors.startDate = 'Startdatum ist erforderlich';
|
||||
if (!startTime) newErrors.startTime = 'Startzeit ist erforderlich';
|
||||
if (!endTime) newErrors.endTime = 'Endzeit ist erforderlich';
|
||||
if (!type) newErrors.type = 'Termintyp ist erforderlich';
|
||||
|
||||
// Vergangenheitsprüfung
|
||||
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) {
|
||||
newErrors.startDate = 'Termine in der Vergangenheit sind nicht erlaubt!';
|
||||
}
|
||||
|
||||
if (type === 'presentation') {
|
||||
if (!media) newErrors.media = 'Bitte eine Präsentation auswählen';
|
||||
if (!slideshowInterval || slideshowInterval < 1)
|
||||
newErrors.slideshowInterval = 'Intervall angeben';
|
||||
}
|
||||
if (type === 'website') {
|
||||
if (!websiteUrl.trim()) newErrors.websiteUrl = 'Webseiten-URL ist erforderlich';
|
||||
}
|
||||
|
||||
// Holiday blocking: prevent creating when range overlaps
|
||||
if (
|
||||
!editMode &&
|
||||
blockHolidays &&
|
||||
startDate &&
|
||||
startTime &&
|
||||
endTime &&
|
||||
typeof isHolidayRange === 'function'
|
||||
) {
|
||||
const s = new Date(
|
||||
startDate.getFullYear(),
|
||||
startDate.getMonth(),
|
||||
startDate.getDate(),
|
||||
startTime.getHours(),
|
||||
startTime.getMinutes()
|
||||
);
|
||||
const e = new Date(
|
||||
startDate.getFullYear(),
|
||||
startDate.getMonth(),
|
||||
startDate.getDate(),
|
||||
endTime.getHours(),
|
||||
endTime.getMinutes()
|
||||
);
|
||||
if (isHolidayRange(s, e)) {
|
||||
newErrors.startDate = 'Dieser Zeitraum liegt in den Ferien und ist gesperrt.';
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrors({});
|
||||
|
||||
const group_id = typeof groupName === 'object' && groupName !== null ? groupName.id : groupName;
|
||||
|
||||
const payload: CustomEventData & { [key: string]: unknown } = {
|
||||
group_id,
|
||||
title,
|
||||
description,
|
||||
start:
|
||||
startDate && startTime
|
||||
? new Date(
|
||||
startDate.getFullYear(),
|
||||
startDate.getMonth(),
|
||||
startDate.getDate(),
|
||||
startTime.getHours(),
|
||||
startTime.getMinutes()
|
||||
).toISOString()
|
||||
: null,
|
||||
end:
|
||||
startDate && endTime
|
||||
? new Date(
|
||||
startDate.getFullYear(),
|
||||
startDate.getMonth(),
|
||||
startDate.getDate(),
|
||||
endTime.getHours(),
|
||||
endTime.getMinutes()
|
||||
).toISOString()
|
||||
: null,
|
||||
type,
|
||||
startDate,
|
||||
startTime,
|
||||
endTime,
|
||||
repeat,
|
||||
weekdays,
|
||||
repeatUntil,
|
||||
skipHolidays,
|
||||
event_type: type,
|
||||
is_active: 1,
|
||||
created_by: 1,
|
||||
};
|
||||
|
||||
if (type === 'presentation') {
|
||||
payload.event_media_id = media?.id;
|
||||
payload.slideshow_interval = slideshowInterval;
|
||||
}
|
||||
|
||||
if (type === 'website') {
|
||||
payload.website_url = websiteUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
let res;
|
||||
if (editMode && initialData && typeof initialData.Id === 'string') {
|
||||
// UPDATE statt CREATE
|
||||
res = await updateEvent(initialData.Id, payload);
|
||||
} else {
|
||||
// CREATE
|
||||
res = await fetch('/api/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
res = await res.json();
|
||||
}
|
||||
if (res.success) {
|
||||
onSave(payload);
|
||||
onClose(); // <--- Box nach erfolgreichem Speichern schließen
|
||||
} else {
|
||||
setErrors({ api: res.error || 'Fehler beim Speichern' });
|
||||
}
|
||||
} catch {
|
||||
setErrors({ api: 'Netzwerkfehler beim Speichern' });
|
||||
}
|
||||
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());
|
||||
|
||||
return (
|
||||
<DialogComponent
|
||||
target="#root"
|
||||
visible={open}
|
||||
width="800px"
|
||||
header={() => (
|
||||
<div
|
||||
style={{
|
||||
background: groupColor || '#f5f5f5',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '6px 6px 0 0',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
fontSize: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{editMode ? 'Termin bearbeiten' : 'Neuen Termin anlegen'}
|
||||
{groupName && (
|
||||
<span style={{ fontWeight: 400, fontSize: 16, marginLeft: 16, color: '#fff' }}>
|
||||
für Raumgruppe: <b>{typeof groupName === 'object' ? groupName.name : groupName}</b>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
showCloseIcon={true}
|
||||
close={onClose}
|
||||
isModal={true}
|
||||
footerTemplate={() => (
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button className="e-btn e-danger" onClick={onClose}>
|
||||
Schließen
|
||||
</button>
|
||||
<button
|
||||
className="e-btn e-success"
|
||||
onClick={handleSave}
|
||||
disabled={isPast} // <--- Button deaktivieren, wenn Termin in Vergangenheit
|
||||
>
|
||||
Termin(e) speichern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div style={{ padding: '24px' }}>
|
||||
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: 1, minWidth: 260 }}>
|
||||
{/* ...Titel, Beschreibung, Datum, Zeit... */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<TextBoxComponent
|
||||
placeholder="Titel"
|
||||
floatLabelType="Auto"
|
||||
value={title}
|
||||
change={e => setTitle(e.value)}
|
||||
/>
|
||||
{errors.title && <div style={{ color: 'red', fontSize: 12 }}>{errors.title}</div>}
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<TextBoxComponent
|
||||
placeholder="Beschreibung"
|
||||
floatLabelType="Auto"
|
||||
multiline={true}
|
||||
value={description}
|
||||
change={e => setDescription(e.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<DatePickerComponent
|
||||
placeholder="Startdatum"
|
||||
floatLabelType="Auto"
|
||||
value={startDate ?? undefined}
|
||||
change={e => setStartDate(e.value)}
|
||||
/>
|
||||
{errors.startDate && (
|
||||
<div style={{ color: 'red', fontSize: 12 }}>{errors.startDate}</div>
|
||||
)}
|
||||
{isPast && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<TimePickerComponent
|
||||
placeholder="Startzeit"
|
||||
floatLabelType="Auto"
|
||||
value={startTime}
|
||||
step={30}
|
||||
change={e => setStartTime(e.value)}
|
||||
/>
|
||||
{errors.startTime && (
|
||||
<div style={{ color: 'red', fontSize: 12 }}>{errors.startTime}</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<TimePickerComponent
|
||||
placeholder="Endzeit"
|
||||
floatLabelType="Auto"
|
||||
value={endTime}
|
||||
step={30}
|
||||
change={e => setEndTime(e.value)}
|
||||
/>
|
||||
{errors.endTime && (
|
||||
<div style={{ color: 'red', fontSize: 12 }}>{errors.endTime}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 260 }}>
|
||||
{/* ...Wiederholung, MultiSelect, Wiederholung bis, Ferien... */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<CheckBoxComponent
|
||||
label="Wiederholender Termin"
|
||||
checked={repeat}
|
||||
change={e => setRepeat(e.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<MultiSelectComponent
|
||||
key={repeat ? 'enabled' : 'disabled'}
|
||||
dataSource={weekdayOptions}
|
||||
fields={{ text: 'label', value: 'value' }}
|
||||
placeholder="Wochentage"
|
||||
value={weekdays}
|
||||
change={e => setWeekdays(e.value as number[])}
|
||||
disabled={!repeat}
|
||||
showDropDownIcon={true}
|
||||
closePopupOnSelect={false}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<DatePickerComponent
|
||||
key={repeat ? 'enabled' : 'disabled'}
|
||||
placeholder="Wiederholung bis"
|
||||
floatLabelType="Auto"
|
||||
value={repeatUntil ?? undefined}
|
||||
change={e => setRepeatUntil(e.value)}
|
||||
disabled={!repeat}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<CheckBoxComponent
|
||||
label="Ferientage berücksichtigen"
|
||||
checked={skipHolidays}
|
||||
change={e => setSkipHolidays(e.checked)}
|
||||
disabled={!repeat}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NEUER ZWEISPALTIGER BEREICH für Termintyp und Zusatzfelder */}
|
||||
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', marginTop: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 260 }}>
|
||||
<div style={{ marginBottom: 12, marginTop: 16 }}>
|
||||
<DropDownListComponent
|
||||
dataSource={typeOptions}
|
||||
fields={{ text: 'label', value: 'value' }}
|
||||
placeholder="Termintyp"
|
||||
value={type}
|
||||
change={e => setType(e.value as string)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
{errors.type && <div style={{ color: 'red', fontSize: 12 }}>{errors.type}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 260 }}>
|
||||
<div style={{ marginBottom: 12, minHeight: 60 }}>
|
||||
{type === 'presentation' && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, marginTop: 16 }}>
|
||||
<button
|
||||
className="e-btn"
|
||||
onClick={() => setMediaModalOpen(true)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Medium auswählen/hochladen
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<b>Ausgewähltes Medium:</b>{' '}
|
||||
{media ? (
|
||||
media.path
|
||||
) : (
|
||||
<span style={{ color: '#888' }}>Kein Medium ausgewählt</span>
|
||||
)}
|
||||
</div>
|
||||
<TextBoxComponent
|
||||
placeholder="Slideshow-Intervall (Sekunden)"
|
||||
floatLabelType="Auto"
|
||||
type="number"
|
||||
value={String(slideshowInterval)}
|
||||
change={e => setSlideshowInterval(Number(e.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{type === 'website' && (
|
||||
<div>
|
||||
<TextBoxComponent
|
||||
placeholder="Webseiten-URL"
|
||||
floatLabelType="Always"
|
||||
value={websiteUrl}
|
||||
change={e => setWebsiteUrl(e.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{mediaModalOpen && (
|
||||
<CustomSelectUploadEventModal
|
||||
open={mediaModalOpen}
|
||||
onClose={() => setMediaModalOpen(false)}
|
||||
onSelect={({ id, path, name }) => {
|
||||
setPendingMedia({ id, path, name });
|
||||
setMediaModalOpen(false);
|
||||
}}
|
||||
selectedFileId={null}
|
||||
/>
|
||||
)}
|
||||
</DialogComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomEventModal;
|
||||
58
dashboard/src/components/CustomMediaInfoPanel.tsx
Normal file
58
dashboard/src/components/CustomMediaInfoPanel.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CustomMediaInfoPanelProps {
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
dateModified: number;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
const CustomMediaInfoPanel: React.FC<CustomMediaInfoPanelProps> = ({
|
||||
name,
|
||||
size,
|
||||
type,
|
||||
dateModified,
|
||||
description,
|
||||
}) => {
|
||||
function formatLocalDate(timestamp: number | undefined | null) {
|
||||
if (!timestamp || isNaN(timestamp)) return '-';
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString('de-DE');
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
border: '1px solid #eee',
|
||||
borderRadius: 8,
|
||||
background: '#fafafa',
|
||||
maxWidth: 400,
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginBottom: 12 }}>Datei-Eigenschaften</h3>
|
||||
<div>
|
||||
<b>Name:</b> {name || '-'}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomMediaInfoPanel;
|
||||
119
dashboard/src/components/CustomSelectUploadEventModal.tsx
Normal file
119
dashboard/src/components/CustomSelectUploadEventModal.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React, { useState } from 'react';
|
||||
import { DialogComponent } from '@syncfusion/ej2-react-popups';
|
||||
import {
|
||||
FileManagerComponent,
|
||||
Inject,
|
||||
NavigationPane,
|
||||
DetailsView,
|
||||
Toolbar,
|
||||
} from '@syncfusion/ej2-react-filemanager';
|
||||
|
||||
const hostUrl = '/api/eventmedia/filemanager/';
|
||||
|
||||
type CustomSelectUploadEventModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (file: { id: string; path: string; name: string }) => void; // name ergänzt
|
||||
selectedFileId?: string | null;
|
||||
};
|
||||
|
||||
const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps> = props => {
|
||||
const { open, onClose, onSelect } = props;
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState<{
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
// Callback für Dateiauswahl
|
||||
interface FileSelectEventArgs {
|
||||
fileDetails: {
|
||||
name: string;
|
||||
isFile: boolean;
|
||||
size: number;
|
||||
// weitere Felder falls benötigt
|
||||
};
|
||||
}
|
||||
|
||||
const handleFileSelect = async (args: FileSelectEventArgs) => {
|
||||
if (args.fileDetails.isFile && args.fileDetails.size > 0) {
|
||||
const filename = args.fileDetails.name;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/eventmedia/find_by_filename?filename=${encodeURIComponent(filename)}`
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSelectedFile({ id: data.id, path: data.file_path, name: filename });
|
||||
} else {
|
||||
setSelectedFile({ id: filename, path: filename, name: filename });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching file details:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Button-Handler
|
||||
const handleSelectClick = () => {
|
||||
if (selectedFile) {
|
||||
onSelect(selectedFile);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogComponent
|
||||
target="#root"
|
||||
visible={open}
|
||||
width="700px"
|
||||
header="Medium auswählen/hochladen"
|
||||
showCloseIcon={true}
|
||||
close={onClose}
|
||||
isModal={true}
|
||||
footerTemplate={() => (
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button className="e-btn" onClick={onClose}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button className="e-btn e-primary" disabled={!selectedFile} onClick={handleSelectClick}>
|
||||
Auswählen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<FileManagerComponent
|
||||
ajaxSettings={{
|
||||
url: hostUrl + 'operations',
|
||||
getImageUrl: hostUrl + 'get-image',
|
||||
uploadUrl: hostUrl + 'upload',
|
||||
downloadUrl: hostUrl + 'download',
|
||||
}}
|
||||
toolbarSettings={{
|
||||
items: [
|
||||
'NewFolder',
|
||||
'Upload',
|
||||
'Download',
|
||||
'Rename',
|
||||
'Delete',
|
||||
'SortBy',
|
||||
'Refresh',
|
||||
'Details',
|
||||
],
|
||||
}}
|
||||
contextMenuSettings={{
|
||||
file: ['Open', '|', 'Download', '|', 'Rename', 'Delete', '|', 'Details'],
|
||||
folder: ['Open', '|', 'Rename', 'Delete', '|', 'Details'],
|
||||
layout: ['SortBy', 'Refresh', '|', 'View', 'Details'],
|
||||
}}
|
||||
allowMultiSelection={false}
|
||||
fileSelect={handleFileSelect}
|
||||
>
|
||||
<Inject services={[NavigationPane, DetailsView, Toolbar]} />
|
||||
</FileManagerComponent>
|
||||
</DialogComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomSelectUploadEventModal;
|
||||
19
dashboard/src/components/SetupModeButton.tsx
Normal file
19
dashboard/src/components/SetupModeButton.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Wrench } from 'lucide-react';
|
||||
|
||||
const SetupModeButton: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<button
|
||||
className="setupmode-btn flex items-center gap-2 px-4 py-2 bg-yellow-200 hover:bg-yellow-300 rounded"
|
||||
onClick={() => navigate('/setup')}
|
||||
title="Erweiterungsmodus starten"
|
||||
>
|
||||
<Wrench size={18} />
|
||||
Erweiterungsmodus
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupModeButton;
|
||||
24
dashboard/src/components/ToastProvider.tsx
Normal file
24
dashboard/src/components/ToastProvider.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React, { createContext, useRef, useContext } from 'react';
|
||||
import { ToastComponent, type ToastModel } from '@syncfusion/ej2-react-notifications';
|
||||
|
||||
const ToastContext = createContext<{ show: (opts: ToastModel) => void }>({ show: () => {} });
|
||||
|
||||
export const useToast = () => useContext(ToastContext);
|
||||
|
||||
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const toastRef = useRef<ToastComponent>(null);
|
||||
|
||||
const show = (opts: ToastModel) => toastRef.current?.show(opts);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ show }}>
|
||||
{children}
|
||||
<ToastComponent
|
||||
ref={toastRef}
|
||||
position={{ X: 'Right', Y: 'Top' }}
|
||||
timeOut={5000} // Standard: 5 Sekunden
|
||||
showCloseButton={false}
|
||||
/>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
204
dashboard/src/dashboard.tsx
Normal file
204
dashboard/src/dashboard.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { fetchGroupsWithClients, restartClient } from './apiClients';
|
||||
import type { Group, Client } from './apiClients';
|
||||
import {
|
||||
GridComponent,
|
||||
ColumnsDirective,
|
||||
ColumnDirective,
|
||||
Page,
|
||||
DetailRow,
|
||||
Inject,
|
||||
Sort,
|
||||
} from '@syncfusion/ej2-react-grids';
|
||||
|
||||
const REFRESH_INTERVAL = 15000; // 15 Sekunden
|
||||
|
||||
// Typ für Collapse-Event
|
||||
// type DetailRowCollapseArgs = {
|
||||
// data?: { id?: string | number };
|
||||
// };
|
||||
|
||||
// Typ für DataBound-Event
|
||||
type DetailRowDataBoundArgs = {
|
||||
data?: { id?: string | number };
|
||||
};
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [expandedGroupIds, setExpandedGroupIds] = useState<string[]>([]);
|
||||
const gridRef = useRef<GridComponent | null>(null);
|
||||
|
||||
// Funktion für das Schließen einer Gruppe (Collapse)
|
||||
// const onDetailCollapse = (args: DetailRowCollapseArgs) => {
|
||||
// if (args && args.data && args.data.id) {
|
||||
// const groupId = String(args.data.id);
|
||||
// setExpandedGroupIds(prev => prev.filter(id => String(id) !== groupId));
|
||||
// }
|
||||
// };
|
||||
|
||||
// // Registriere das Event nach dem Mount am Grid
|
||||
// useEffect(() => {
|
||||
// if (gridRef.current) {
|
||||
// gridRef.current.detailCollapse = onDetailCollapse;
|
||||
// }
|
||||
// }, []);
|
||||
|
||||
// Optimiertes Update: Nur bei echten Datenänderungen wird das Grid aktualisiert
|
||||
useEffect(() => {
|
||||
let lastGroups: Group[] = [];
|
||||
const fetchAndUpdate = async () => {
|
||||
const newGroups = await fetchGroupsWithClients();
|
||||
// Vergleiche nur die relevanten Felder (id, clients, is_alive)
|
||||
const changed =
|
||||
lastGroups.length !== newGroups.length ||
|
||||
lastGroups.some((g, i) => {
|
||||
const ng = newGroups[i];
|
||||
if (!ng || g.id !== ng.id || g.clients.length !== ng.clients.length) return true;
|
||||
// Optional: Vergleiche tiefer, z.B. Alive-Status
|
||||
for (let j = 0; j < g.clients.length; j++) {
|
||||
if (
|
||||
g.clients[j].uuid !== ng.clients[j].uuid ||
|
||||
g.clients[j].is_alive !== ng.clients[j].is_alive
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (changed) {
|
||||
setGroups(newGroups);
|
||||
lastGroups = newGroups;
|
||||
setTimeout(() => {
|
||||
expandedGroupIds.forEach(id => {
|
||||
const rowIndex = newGroups.findIndex(g => String(g.id) === String(id));
|
||||
if (rowIndex !== -1 && gridRef.current) {
|
||||
gridRef.current.detailRowModule.expand(rowIndex);
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAndUpdate();
|
||||
const interval = setInterval(fetchAndUpdate, REFRESH_INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, [expandedGroupIds]);
|
||||
|
||||
// Health-Badge
|
||||
const getHealthBadge = (group: Group) => {
|
||||
const total = group.clients.length;
|
||||
const alive = group.clients.filter((c: Client) => c.is_alive).length;
|
||||
const ratio = total === 0 ? 0 : alive / total;
|
||||
let color = 'danger';
|
||||
let text = `${alive} / ${total} offline`;
|
||||
if (ratio === 1) {
|
||||
color = 'success';
|
||||
text = `${alive} / ${total} alive`;
|
||||
} else if (ratio >= 0.5) {
|
||||
color = 'warning';
|
||||
text = `${alive} / ${total} teilw. alive`;
|
||||
}
|
||||
return <span className={`e-badge e-badge-${color}`}>{text}</span>;
|
||||
};
|
||||
|
||||
// Einfache Tabelle für Clients einer Gruppe
|
||||
const getClientTable = (group: Group) => (
|
||||
<div style={{ maxHeight: 300, overflowY: 'auto', marginBottom: '5px' }}>
|
||||
<GridComponent dataSource={group.clients} allowSorting={true} height={'auto'}>
|
||||
<ColumnsDirective>
|
||||
<ColumnDirective field="description" headerText="Beschreibung" width="150" />
|
||||
<ColumnDirective field="ip" headerText="IP" width="120" />
|
||||
{/* <ColumnDirective
|
||||
field="last_alive"
|
||||
headerText="Letztes Lebenszeichen"
|
||||
width="180"
|
||||
template={(props: { last_alive: string | null }) => {
|
||||
if (!props.last_alive) return '-';
|
||||
const dateStr = props.last_alive.endsWith('Z')
|
||||
? props.last_alive
|
||||
: props.last_alive + 'Z';
|
||||
const date = new Date(dateStr);
|
||||
return isNaN(date.getTime()) ? props.last_alive : date.toLocaleString();
|
||||
}}
|
||||
/> */}
|
||||
<ColumnDirective
|
||||
field="is_alive"
|
||||
headerText="Alive"
|
||||
width="100"
|
||||
template={(props: { is_alive: boolean }) => (
|
||||
<span className={`e-badge e-badge-${props.is_alive ? 'success' : 'danger'}`}>
|
||||
{props.is_alive ? 'alive' : 'offline'}
|
||||
</span>
|
||||
)}
|
||||
sortComparer={(a, b) => (a === b ? 0 : a ? -1 : 1)}
|
||||
/>
|
||||
<ColumnDirective
|
||||
headerText="Aktionen"
|
||||
width="150"
|
||||
template={(props: { uuid: string }) => (
|
||||
<button className="e-btn e-primary" onClick={() => handleRestartClient(props.uuid)}>
|
||||
Neustart
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</ColumnsDirective>
|
||||
<Inject services={[Sort]} />
|
||||
</GridComponent>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Neustart-Logik
|
||||
const handleRestartClient = async (uuid: string) => {
|
||||
try {
|
||||
const result = await restartClient(uuid);
|
||||
alert(`Neustart erfolgreich: ${result.message}`);
|
||||
} catch (error: unknown) {
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
alert(`Fehler beim Neustart: ${(error as { message: string }).message}`);
|
||||
} else {
|
||||
alert('Unbekannter Fehler beim Neustart');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// SyncFusion Grid liefert im Event die Zeile/Gruppe
|
||||
const onDetailDataBound = (args: DetailRowDataBoundArgs) => {
|
||||
if (args && args.data && args.data.id) {
|
||||
const groupId = String(args.data.id);
|
||||
setExpandedGroupIds(prev => (prev.includes(groupId) ? prev : [...prev, groupId]));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header className="mb-8 pb-4 border-b-2 border-[#d6c3a6]">
|
||||
<h2 className="text-3xl font-extrabold mb-2">Dashboard</h2>
|
||||
</header>
|
||||
<h3 className="text-lg font-semibold mt-6 mb-4">Raumgruppen Übersicht</h3>
|
||||
<GridComponent
|
||||
dataSource={groups}
|
||||
allowPaging={true}
|
||||
pageSettings={{ pageSize: 5 }}
|
||||
height={400}
|
||||
detailTemplate={(props: Group) => getClientTable(props)}
|
||||
detailDataBound={onDetailDataBound}
|
||||
ref={gridRef}
|
||||
>
|
||||
<Inject services={[Page, DetailRow]} />
|
||||
<ColumnsDirective>
|
||||
<ColumnDirective field="name" headerText="Raumgruppe" width="180" />
|
||||
<ColumnDirective
|
||||
headerText="Health"
|
||||
width="160"
|
||||
template={(props: Group) => getHealthBadge(props)}
|
||||
/>
|
||||
</ColumnsDirective>
|
||||
</GridComponent>
|
||||
{groups.length === 0 && (
|
||||
<div className="col-span-full text-center text-gray-400">Keine Gruppen gefunden.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
87
dashboard/src/einstellungen.tsx
Normal file
87
dashboard/src/einstellungen.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { listHolidays, uploadHolidaysCsv, type Holiday } from './apiHolidays';
|
||||
|
||||
const Einstellungen: React.FC = () => {
|
||||
const [file, setFile] = React.useState<File | null>(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [message, setMessage] = React.useState<string | null>(null);
|
||||
const [holidays, setHolidays] = React.useState<Holiday[]>([]);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
try {
|
||||
const data = await listHolidays();
|
||||
setHolidays(data.holidays);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Ferien';
|
||||
setMessage(msg);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const onUpload = async () => {
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await uploadHolidaysCsv(file);
|
||||
setMessage(`Import erfolgreich: ${res.inserted} neu, ${res.updated} aktualisiert.`);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Fehler beim Import.';
|
||||
setMessage(msg);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-4">Einstellungen</h2>
|
||||
<div className="space-y-4">
|
||||
<section className="p-4 border rounded-md">
|
||||
<h3 className="font-semibold mb-2">Schulferien importieren</h3>
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
Unterstützte Formate:
|
||||
<br />• CSV mit Kopfzeile: <code>name</code>, <code>start_date</code>,{' '}
|
||||
<code>end_date</code>, optional <code>region</code>
|
||||
<br />• TXT/CSV ohne Kopfzeile mit Spalten: interner Name, <strong>Name</strong>,{' '}
|
||||
<strong>Start (YYYYMMDD)</strong>, <strong>Ende (YYYYMMDD)</strong>, optional interne
|
||||
Info (ignoriert)
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,text/csv,.txt,text/plain"
|
||||
onChange={e => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<button className="e-btn e-primary" onClick={onUpload} disabled={!file || busy}>
|
||||
{busy ? 'Importiere…' : 'CSV/TXT importieren'}
|
||||
</button>
|
||||
</div>
|
||||
{message && <div className="mt-2 text-sm">{message}</div>}
|
||||
</section>
|
||||
|
||||
<section className="p-4 border rounded-md">
|
||||
<h3 className="font-semibold mb-2">Importierte Ferien</h3>
|
||||
{holidays.length === 0 ? (
|
||||
<div className="text-sm text-gray-600">Keine Einträge vorhanden.</div>
|
||||
) : (
|
||||
<ul className="text-sm list-disc pl-6">
|
||||
{holidays.slice(0, 20).map(h => (
|
||||
<li key={h.id}>
|
||||
{h.name}: {h.start_date} – {h.end_date}
|
||||
{h.region ? ` (${h.region})` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Einstellungen;
|
||||
52
dashboard/src/groupColors.ts
Normal file
52
dashboard/src/groupColors.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// 20 gut unterscheidbare Farben für Gruppen
|
||||
|
||||
export const groupColorPalette: string[] = [
|
||||
'#1E90FF', // Blau
|
||||
'#28A745', // Grün
|
||||
'#FFC107', // Gelb
|
||||
'#DC3545', // Rot
|
||||
'#6F42C1', // Lila
|
||||
'#20C997', // Türkis
|
||||
'#FD7E14', // Orange
|
||||
'#6610F2', // Violett
|
||||
'#17A2B8', // Cyan
|
||||
'#E83E8C', // Pink
|
||||
'#FF5733', // Koralle
|
||||
'#2ECC40', // Hellgrün
|
||||
'#FFB300', // Dunkelgelb
|
||||
'#00796B', // Petrol
|
||||
'#C70039', // Dunkelrot
|
||||
'#8D6E63', // Braun
|
||||
'#607D8B', // Grau-Blau
|
||||
'#00B8D4', // Türkisblau
|
||||
'#FF6F00', // Dunkelorange
|
||||
'#9C27B0', // Dunkellila
|
||||
];
|
||||
|
||||
// Gibt für eine Gruppen-ID immer dieselbe Farbe zurück (Index basiert auf Gruppenliste)
|
||||
export function getGroupColor(groupId: string, groups: { id: string }[]): string {
|
||||
const colorPalette = [
|
||||
'#1E90FF',
|
||||
'#28A745',
|
||||
'#FFC107',
|
||||
'#DC3545',
|
||||
'#6F42C1',
|
||||
'#20C997',
|
||||
'#FD7E14',
|
||||
'#6610F2',
|
||||
'#17A2B8',
|
||||
'#E83E8C',
|
||||
'#FF5733',
|
||||
'#2ECC40',
|
||||
'#FFB300',
|
||||
'#00796B',
|
||||
'#C70039',
|
||||
'#8D6E63',
|
||||
'#607D8B',
|
||||
'#00B8D4',
|
||||
'#FF6F00',
|
||||
'#9C27B0',
|
||||
];
|
||||
const idx = groups.findIndex(g => g.id === groupId);
|
||||
return colorPalette[idx % colorPalette.length];
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
76
dashboard/src/index.css
Normal file
76
dashboard/src/index.css
Normal file
@@ -0,0 +1,76 @@
|
||||
/* @tailwind base;
|
||||
@tailwind components; */
|
||||
|
||||
/* @tailwind utilities; */
|
||||
|
||||
/* :root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color-scheme: light dark;
|
||||
color: rgb(255 255 255 / 87%);
|
||||
background-color: #242424;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizelegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
/* button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
} */
|
||||
|
||||
/* @media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
} */
|
||||
519
dashboard/src/infoscreen_groups.tsx
Normal file
519
dashboard/src/infoscreen_groups.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { KanbanComponent } from '@syncfusion/ej2-react-kanban';
|
||||
import { fetchClients, updateClientGroup } from './apiClients';
|
||||
import { fetchGroups, createGroup, deleteGroup, renameGroup } from './apiGroups';
|
||||
import type { Client } from './apiClients';
|
||||
import type { KanbanComponent as KanbanComponentType } from '@syncfusion/ej2-react-kanban';
|
||||
import { DialogComponent } from '@syncfusion/ej2-react-popups';
|
||||
import { useToast } from './components/ToastProvider';
|
||||
import { L10n } from '@syncfusion/ej2-base';
|
||||
|
||||
interface KanbanClient extends Client {
|
||||
Id: string;
|
||||
Status: string; // Raumgruppe (Gruppenname)
|
||||
Summary: string; // Anzeigename
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: number;
|
||||
name: string;
|
||||
// weitere Felder möglich
|
||||
}
|
||||
|
||||
interface KanbanDragEventArgs {
|
||||
element: HTMLElement | HTMLElement[];
|
||||
data: KanbanClient | KanbanClient[];
|
||||
event?: { event?: MouseEvent };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface KanbanComponentWithClear extends KanbanComponentType {
|
||||
clearSelection: () => void;
|
||||
}
|
||||
|
||||
const de = {
|
||||
title: 'Gruppen',
|
||||
newGroup: 'Neue Raumgruppe',
|
||||
renameGroup: 'Gruppe umbenennen',
|
||||
deleteGroup: 'Gruppe löschen',
|
||||
add: 'Hinzufügen',
|
||||
cancel: 'Abbrechen',
|
||||
rename: 'Umbenennen',
|
||||
confirmDelete: 'Löschbestätigung',
|
||||
reallyDelete: (name: string) => `Möchten Sie die Gruppe <b>${name}</b> wirklich löschen?`,
|
||||
clientsMoved: 'Alle Clients werden in "Nicht zugeordnet" verschoben.',
|
||||
groupCreated: 'Gruppe angelegt',
|
||||
groupDeleted: 'Gruppe gelöscht. Clients in "Nicht zugeordnet" verschoben',
|
||||
groupRenamed: 'Gruppenname geändert',
|
||||
selectGroup: 'Gruppe wählen',
|
||||
newName: 'Neuer Name',
|
||||
warning: 'Achtung:',
|
||||
yesDelete: 'Ja, löschen',
|
||||
};
|
||||
|
||||
L10n.load({
|
||||
de: {
|
||||
kanban: {
|
||||
items: 'Clients',
|
||||
addTitle: 'Neue Karte hinzufügen',
|
||||
editTitle: 'Karte bearbeiten',
|
||||
deleteTitle: 'Karte löschen',
|
||||
edit: 'Bearbeiten',
|
||||
delete: 'Löschen',
|
||||
save: 'Speichern',
|
||||
cancel: 'Abbrechen',
|
||||
yes: 'Ja',
|
||||
no: 'Nein',
|
||||
noCard: 'Keine Clients vorhanden',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const Infoscreen_groups: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const [clients, setClients] = useState<KanbanClient[]>([]);
|
||||
const [groups, setGroups] = useState<{ keyField: string; headerText: string; id?: number }[]>([]);
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
const [draggedCard, setDraggedCard] = useState<{ id: string; fromColumn: string } | null>(null);
|
||||
const [renameDialog, setRenameDialog] = useState<{
|
||||
open: boolean;
|
||||
oldName: string;
|
||||
newName: string;
|
||||
}>({ open: false, oldName: '', newName: '' });
|
||||
const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; groupName: string }>({
|
||||
open: false,
|
||||
groupName: '',
|
||||
});
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const kanbanRef = useRef<KanbanComponentType | null>(null); // Ref für Kanban
|
||||
|
||||
// Lade Gruppen und Clients
|
||||
useEffect(() => {
|
||||
let groupMap: Record<number, string> = {};
|
||||
fetchGroups().then((groupData: Group[]) => {
|
||||
const kanbanGroups = groupData.map(g => ({
|
||||
keyField: g.name,
|
||||
headerText: g.name,
|
||||
id: g.id,
|
||||
}));
|
||||
setGroups(kanbanGroups);
|
||||
|
||||
groupMap = Object.fromEntries(groupData.map(g => [g.id, g.name]));
|
||||
|
||||
fetchClients().then(data => {
|
||||
setClients(
|
||||
data.map((c, i) => ({
|
||||
...c,
|
||||
Id: c.uuid,
|
||||
Status:
|
||||
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}`,
|
||||
}))
|
||||
);
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Neue Gruppe anlegen (persistiert per API)
|
||||
const handleAddGroup = async () => {
|
||||
if (!newGroupName.trim()) return;
|
||||
try {
|
||||
const newGroup = await createGroup(newGroupName);
|
||||
toast.show({
|
||||
content: de.groupCreated,
|
||||
cssClass: 'e-toast-success',
|
||||
timeOut: 5000,
|
||||
showCloseButton: false,
|
||||
});
|
||||
setGroups([
|
||||
...groups,
|
||||
{ keyField: newGroup.name, headerText: newGroup.name, id: newGroup.id },
|
||||
]);
|
||||
setNewGroupName('');
|
||||
setShowDialog(false);
|
||||
} catch (err) {
|
||||
toast.show({
|
||||
content: (err as Error).message,
|
||||
cssClass: 'e-toast-danger',
|
||||
timeOut: 0,
|
||||
showCloseButton: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Löschen einer Gruppe
|
||||
const handleDeleteGroup = async (groupName: string) => {
|
||||
try {
|
||||
// Clients der Gruppe in "Nicht zugeordnet" verschieben
|
||||
const groupClients = clients.filter(c => c.Status === groupName);
|
||||
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(
|
||||
groupClients.map(c => c.Id),
|
||||
target.id
|
||||
);
|
||||
}
|
||||
await deleteGroup(groupName);
|
||||
toast.show({
|
||||
content: de.groupDeleted,
|
||||
cssClass: 'e-toast-success',
|
||||
timeOut: 5000,
|
||||
showCloseButton: false,
|
||||
});
|
||||
// Gruppen und Clients neu laden
|
||||
const groupData = await fetchGroups();
|
||||
const groupMap = Object.fromEntries(groupData.map((g: Group) => [g.id, g.name]));
|
||||
setGroups(groupData.map((g: Group) => ({ keyField: g.name, headerText: g.name, id: g.id })));
|
||||
const data = await fetchClients();
|
||||
setClients(
|
||||
data.map((c, i) => ({
|
||||
...c,
|
||||
Id: c.uuid,
|
||||
Status:
|
||||
typeof c.group_id === 'number' && groupMap[c.group_id]
|
||||
? groupMap[c.group_id]
|
||||
: 'Nicht zugeordnet',
|
||||
Summary: c.description || `Client ${i + 1}`,
|
||||
}))
|
||||
);
|
||||
} catch (err) {
|
||||
toast.show({
|
||||
content: (err as Error).message,
|
||||
cssClass: 'e-toast-danger',
|
||||
timeOut: 0,
|
||||
showCloseButton: true,
|
||||
});
|
||||
}
|
||||
setDeleteDialog({ open: false, groupName: '' });
|
||||
};
|
||||
|
||||
// Umbenennen einer Gruppe
|
||||
const handleRenameGroup = async () => {
|
||||
try {
|
||||
await renameGroup(renameDialog.oldName, renameDialog.newName);
|
||||
toast.show({
|
||||
content: de.groupRenamed,
|
||||
cssClass: 'e-toast-success',
|
||||
timeOut: 5000,
|
||||
showCloseButton: false,
|
||||
});
|
||||
// Gruppen und Clients neu laden
|
||||
const groupData = await fetchGroups();
|
||||
const groupMap = Object.fromEntries(groupData.map((g: Group) => [g.id, g.name]));
|
||||
setGroups(groupData.map((g: Group) => ({ keyField: g.name, headerText: g.name, id: g.id })));
|
||||
const data = await fetchClients();
|
||||
setClients(
|
||||
data.map((c, i) => ({
|
||||
...c,
|
||||
Id: c.uuid,
|
||||
Status:
|
||||
typeof c.group_id === 'number' && groupMap[c.group_id]
|
||||
? groupMap[c.group_id]
|
||||
: 'Nicht zugeordnet',
|
||||
Summary: c.description || `Client ${i + 1}`,
|
||||
}))
|
||||
);
|
||||
} catch (err) {
|
||||
toast.show({
|
||||
content: (err as Error).message,
|
||||
cssClass: 'e-toast-danger',
|
||||
timeOut: 0,
|
||||
showCloseButton: true,
|
||||
});
|
||||
}
|
||||
setRenameDialog({ open: false, oldName: '', newName: '' });
|
||||
};
|
||||
|
||||
const handleDragStart = (args: KanbanDragEventArgs) => {
|
||||
const element = Array.isArray(args.element) ? args.element[0] : args.element;
|
||||
const cardId = element.getAttribute('data-id');
|
||||
const fromColumn = element.getAttribute('data-key');
|
||||
setDraggedCard({ id: cardId || '', fromColumn: fromColumn || '' });
|
||||
};
|
||||
|
||||
const handleCardDrop = async (args: KanbanDragEventArgs) => {
|
||||
if (!draggedCard) return;
|
||||
|
||||
const mouseEvent = args.event?.event;
|
||||
let targetGroupName = '';
|
||||
|
||||
if (mouseEvent && mouseEvent.clientX && mouseEvent.clientY) {
|
||||
const targetElement = document.elementFromPoint(mouseEvent.clientX, mouseEvent.clientY);
|
||||
const kanbanColumn =
|
||||
targetElement?.closest('[data-key]') || targetElement?.closest('.e-content-row');
|
||||
if (kanbanColumn) {
|
||||
const columnKey = kanbanColumn.getAttribute('data-key');
|
||||
if (columnKey) {
|
||||
targetGroupName = columnKey;
|
||||
} else {
|
||||
const headerElement = kanbanColumn.querySelector('.e-header-text');
|
||||
targetGroupName = headerElement?.textContent?.trim() || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
if (!targetGroupName) {
|
||||
const targetElement = Array.isArray(args.element) ? args.element[0] : args.element;
|
||||
const cardWrapper = targetElement.closest('.e-card-wrapper');
|
||||
const contentRow = cardWrapper?.closest('.e-content-row');
|
||||
const headerText = contentRow?.querySelector('.e-header-text');
|
||||
targetGroupName = headerText?.textContent?.trim() || '';
|
||||
}
|
||||
|
||||
if (!targetGroupName || targetGroupName === draggedCard.fromColumn) {
|
||||
setDraggedCard(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const dropped = Array.isArray(args.data) ? args.data : [args.data];
|
||||
const clientIds = dropped.map((card: KanbanClient) => card.Id);
|
||||
|
||||
try {
|
||||
// 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[]) => {
|
||||
const groupMap = Object.fromEntries(groupData.map(g => [g.id, g.name]));
|
||||
setGroups(
|
||||
groupData.map(g => ({
|
||||
keyField: g.name,
|
||||
headerText: g.name,
|
||||
id: g.id,
|
||||
}))
|
||||
);
|
||||
fetchClients().then(data => {
|
||||
setClients(
|
||||
data.map((c, i) => ({
|
||||
...c,
|
||||
Id: c.uuid,
|
||||
Status:
|
||||
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
|
||||
setTimeout(() => {
|
||||
(kanbanRef.current as KanbanComponentWithClear)?.clearSelection();
|
||||
setTimeout(() => {
|
||||
(kanbanRef.current as KanbanComponentWithClear)?.clearSelection();
|
||||
}, 100);
|
||||
}, 50);
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
alert('Fehler beim Aktualisieren der Clients');
|
||||
}
|
||||
setDraggedCard(null);
|
||||
};
|
||||
|
||||
// Spalten-Array ohne Header-Buttons/Template
|
||||
const kanbanColumns = groups.map(group => ({
|
||||
keyField: group.keyField,
|
||||
headerText: group.headerText,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div id="dialog-target">
|
||||
<h2 className="text-xl font-bold mb-4">{de.title}</h2>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded"
|
||||
onClick={() => setShowDialog(true)}
|
||||
>
|
||||
{de.newGroup}
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 bg-yellow-500 text-white rounded"
|
||||
onClick={() => setRenameDialog({ open: true, oldName: '', newName: '' })}
|
||||
>
|
||||
{de.renameGroup}
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded"
|
||||
onClick={() => setDeleteDialog({ open: true, groupName: '' })}
|
||||
>
|
||||
{de.deleteGroup}
|
||||
</button>
|
||||
</div>
|
||||
<KanbanComponent
|
||||
locale="de"
|
||||
id="kanban"
|
||||
keyField="Status"
|
||||
dataSource={clients}
|
||||
cardSettings={{
|
||||
headerField: 'Summary',
|
||||
selectionType: 'Multiple',
|
||||
}}
|
||||
allowDragAndDrop={true}
|
||||
dragStart={handleDragStart}
|
||||
dragStop={handleCardDrop}
|
||||
ref={kanbanRef}
|
||||
columns={kanbanColumns}
|
||||
/>
|
||||
{showDialog && (
|
||||
<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.newGroup}</h3>
|
||||
<input
|
||||
className="border p-2 mb-2 w-full"
|
||||
value={newGroupName}
|
||||
onChange={e => setNewGroupName(e.target.value)}
|
||||
placeholder="Raumname"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button className="bg-blue-500 text-white px-4 py-2 rounded" onClick={handleAddGroup}>
|
||||
{de.add}
|
||||
</button>
|
||||
<button
|
||||
className="bg-gray-300 px-4 py-2 rounded"
|
||||
onClick={() => setShowDialog(false)}
|
||||
>
|
||||
{de.cancel}
|
||||
</button>
|
||||
</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>
|
||||
{groups
|
||||
.filter(g => g.headerText !== 'Nicht zugeordnet')
|
||||
.map(g => (
|
||||
<option key={g.keyField} value={g.headerText}>
|
||||
{g.headerText}
|
||||
</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">
|
||||
<button
|
||||
className="bg-blue-500 text-white px-4 py-2 rounded"
|
||||
onClick={handleRenameGroup}
|
||||
disabled={!renameDialog.oldName || !renameDialog.newName}
|
||||
>
|
||||
{de.rename}
|
||||
</button>
|
||||
<button
|
||||
className="bg-gray-300 px-4 py-2 rounded"
|
||||
onClick={() => setRenameDialog({ open: false, oldName: '', newName: '' })}
|
||||
>
|
||||
{de.cancel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{deleteDialog.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.deleteGroup}</h3>
|
||||
<select
|
||||
className="border p-2 mb-2 w-full"
|
||||
value={deleteDialog.groupName}
|
||||
onChange={e => setDeleteDialog({ ...deleteDialog, groupName: e.target.value })}
|
||||
>
|
||||
<option value="">{de.selectGroup}</option>
|
||||
{groups
|
||||
.filter(g => g.headerText !== 'Nicht zugeordnet')
|
||||
.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)}
|
||||
disabled={!deleteDialog.groupName}
|
||||
>
|
||||
{de.deleteGroup}
|
||||
</button>
|
||||
<button
|
||||
className="bg-gray-300 px-4 py-2 rounded"
|
||||
onClick={() => setDeleteDialog({ open: false, groupName: '' })}
|
||||
>
|
||||
{de.cancel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showDeleteConfirm && deleteDialog.groupName && (
|
||||
<DialogComponent
|
||||
width="350px"
|
||||
header={de.confirmDelete}
|
||||
visible={showDeleteConfirm}
|
||||
close={() => setShowDeleteConfirm(false)}
|
||||
footerTemplate={() => (
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
className="bg-red-500 text-white px-4 py-2 rounded"
|
||||
onClick={() => {
|
||||
handleDeleteGroup(deleteDialog.groupName);
|
||||
setShowDeleteConfirm(false);
|
||||
}}
|
||||
>
|
||||
{de.yesDelete}
|
||||
</button>
|
||||
<button
|
||||
className="bg-gray-300 px-4 py-2 rounded"
|
||||
onClick={() => {
|
||||
setShowDeleteConfirm(false);
|
||||
setDeleteDialog({ open: false, groupName: '' });
|
||||
}}
|
||||
>
|
||||
{de.cancel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
Möchten Sie die Gruppe <b>{deleteDialog.groupName}</b> wirklich löschen?
|
||||
<br />
|
||||
<span className="text-sm text-gray-500">{de.clientsMoved}</span>
|
||||
</div>
|
||||
</DialogComponent>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Infoscreen_groups;
|
||||
12
dashboard/src/logout.tsx
Normal file
12
dashboard/src/logout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
const Logout: React.FC = () => (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold mb-4">Abmeldung</h2>
|
||||
<p>Sie haben sich erfolgreich abgemeldet.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Logout;
|
||||
19
dashboard/src/main.tsx
Normal file
19
dashboard/src/main.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App.tsx';
|
||||
import { registerLicense } from '@syncfusion/ej2-base';
|
||||
import '@syncfusion/ej2-base/styles/material3.css';
|
||||
import '@syncfusion/ej2-navigations/styles/material3.css';
|
||||
import '@syncfusion/ej2-buttons/styles/material3.css';
|
||||
|
||||
// Setze hier deinen Lizenzschlüssel ein
|
||||
registerLicense(
|
||||
'ORg4AjUWIQA/Gnt3VVhhQlJDfV5AQmBIYVp/TGpJfl96cVxMZVVBJAtUQF1hTH5VdENiXX1dcHxUQWNVWkd2'
|
||||
);
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
117
dashboard/src/media.tsx
Normal file
117
dashboard/src/media.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import CustomMediaInfoPanel from './components/CustomMediaInfoPanel';
|
||||
import {
|
||||
FileManagerComponent,
|
||||
Inject,
|
||||
NavigationPane,
|
||||
DetailsView,
|
||||
Toolbar,
|
||||
} from '@syncfusion/ej2-react-filemanager';
|
||||
|
||||
const hostUrl = '/api/eventmedia/filemanager/'; // Dein Backend-Endpunkt für FileManager
|
||||
|
||||
const Media: React.FC = () => {
|
||||
// State für die angezeigten Dateidetails
|
||||
const [fileDetails] = useState<null | {
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
dateModified: number;
|
||||
description?: string | null;
|
||||
}>(null);
|
||||
// Ansicht: 'LargeIcons', 'Details'
|
||||
const [viewMode, setViewMode] = useState<'LargeIcons' | 'Details'>('LargeIcons');
|
||||
const fileManagerRef = useRef<FileManagerComponent | null>(null);
|
||||
|
||||
// Hilfsfunktion für Datum in Browser-Zeitzone
|
||||
function formatLocalDate(timestamp: number) {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString('de-DE'); // Zeigt lokale Zeit des Browsers
|
||||
}
|
||||
|
||||
// Ansicht umschalten, ohne Remount
|
||||
React.useEffect(() => {
|
||||
if (fileManagerRef.current) {
|
||||
const element = fileManagerRef.current.element as HTMLElement & { ej2_instances?: unknown[] };
|
||||
if (element && element.ej2_instances && element.ej2_instances[0]) {
|
||||
// Typisiere Instanz als unknown, da kein offizieller Typ vorhanden
|
||||
const instanz = element.ej2_instances[0] as { view: string; dataBind: () => void };
|
||||
instanz.view = viewMode;
|
||||
instanz.dataBind();
|
||||
}
|
||||
}
|
||||
}, [viewMode]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-4">Medien</h2>
|
||||
{/* Ansicht-Umschalter */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<button
|
||||
className={viewMode === 'LargeIcons' ? 'e-btn e-active' : 'e-btn'}
|
||||
onClick={() => setViewMode('LargeIcons')}
|
||||
style={{ marginRight: 8 }}
|
||||
>
|
||||
Icons
|
||||
</button>
|
||||
<button
|
||||
className={viewMode === 'Details' ? 'e-btn e-active' : 'e-btn'}
|
||||
onClick={() => setViewMode('Details')}
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
{/* Debug-Ausgabe entfernt, da ReactNode erwartet wird */}
|
||||
<FileManagerComponent
|
||||
ref={fileManagerRef}
|
||||
ajaxSettings={{
|
||||
url: hostUrl + 'operations',
|
||||
getImageUrl: hostUrl + 'get-image',
|
||||
uploadUrl: hostUrl + 'upload',
|
||||
downloadUrl: hostUrl + 'download',
|
||||
}}
|
||||
toolbarSettings={{
|
||||
items: [
|
||||
'NewFolder',
|
||||
'Upload',
|
||||
'Download',
|
||||
'Rename',
|
||||
'Delete',
|
||||
'SortBy',
|
||||
'Refresh',
|
||||
'Details',
|
||||
],
|
||||
}}
|
||||
contextMenuSettings={{
|
||||
file: ['Open', '|', 'Download', '|', 'Rename', 'Delete', '|', 'Details'],
|
||||
folder: ['Open', '|', 'Rename', 'Delete', '|', 'Details'],
|
||||
layout: ['SortBy', 'Refresh', '|', 'View', 'Details'],
|
||||
}}
|
||||
allowMultiSelection={false}
|
||||
view={viewMode}
|
||||
detailsViewSettings={{
|
||||
columns: [
|
||||
{ field: 'name', headerText: 'Name', minWidth: '120', width: '200' },
|
||||
{ field: 'size', headerText: 'Größe', minWidth: '80', width: '100' },
|
||||
{
|
||||
field: 'dateModified',
|
||||
headerText: 'Upload-Datum',
|
||||
minWidth: '120',
|
||||
width: '180',
|
||||
template: (data: { dateModified: number }) => formatLocalDate(data.dateModified),
|
||||
},
|
||||
{ field: 'type', headerText: 'Typ', minWidth: '80', width: '100' },
|
||||
],
|
||||
}}
|
||||
menuClick={() => {}}
|
||||
>
|
||||
<Inject services={[NavigationPane, DetailsView, Toolbar]} />
|
||||
</FileManagerComponent>
|
||||
{/* Details-Panel anzeigen, wenn Details verfügbar sind */}
|
||||
{fileDetails && <CustomMediaInfoPanel {...fileDetails} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Media;
|
||||
172
dashboard/src/programminfo.tsx
Normal file
172
dashboard/src/programminfo.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface ProgramInfo {
|
||||
appName: string;
|
||||
version: string;
|
||||
copyright: string;
|
||||
supportContact: string;
|
||||
description: string;
|
||||
techStack: {
|
||||
[key: string]: string;
|
||||
};
|
||||
openSourceComponents: {
|
||||
frontend: { name: string; license: string }[];
|
||||
backend: { name: string; license: string }[];
|
||||
};
|
||||
buildInfo: {
|
||||
buildDate: string;
|
||||
commitId: string;
|
||||
};
|
||||
changelog: {
|
||||
version: string;
|
||||
date: string;
|
||||
changes: string[];
|
||||
}[];
|
||||
}
|
||||
|
||||
const Programminfo: React.FC = () => {
|
||||
const [info, setInfo] = useState<ProgramInfo | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/program-info.json')
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Netzwerk-Antwort war nicht ok');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => setInfo(data))
|
||||
.catch(error => {
|
||||
console.error('Fehler beim Laden der Programminformationen:', error);
|
||||
setError('Informationen konnten nicht geladen werden.');
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-4 text-red-600">Fehler</h2>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!info) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-4">Programminfo</h2>
|
||||
<p>Lade Informationen...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-2">{info.appName}</h2>
|
||||
<p className="text-gray-600">{info.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{/* Allgemeine Infos & Build */}
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h3 className="text-xl font-semibold mb-4 border-b pb-2">Allgemein</h3>
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
<strong>Version:</strong> {info.version}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Copyright:</strong> {info.copyright}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Support:</strong>{' '}
|
||||
<a href={`mailto:${info.supportContact}`} className="text-blue-600 hover:underline">
|
||||
{info.supportContact}
|
||||
</a>
|
||||
</p>
|
||||
<hr className="my-4" />
|
||||
<h4 className="font-semibold">Build-Informationen</h4>
|
||||
<p>
|
||||
<strong>Build-Datum:</strong>{' '}
|
||||
{new Date(info.buildInfo.buildDate).toLocaleString('de-DE')}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Commit-ID:</strong>{' '}
|
||||
<span className="font-mono text-sm bg-gray-100 p-1 rounded">
|
||||
{info.buildInfo.commitId}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technischer Stack */}
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h3 className="text-xl font-semibold mb-4 border-b pb-2">Technologie-Stack</h3>
|
||||
<ul className="list-disc list-inside space-y-2">
|
||||
{Object.entries(info.techStack).map(([key, value]) => (
|
||||
<li key={key}>
|
||||
<span className="font-semibold capitalize">{key}:</span> {value}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Changelog */}
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-4">Änderungsprotokoll (Changelog)</h3>
|
||||
<div className="space-y-6">
|
||||
{info.changelog.map(log => (
|
||||
<div key={log.version} className="bg-white p-6 rounded-lg shadow">
|
||||
<h4 className="font-bold text-lg mb-2">
|
||||
Version {log.version}{' '}
|
||||
<span className="text-sm font-normal text-gray-500">
|
||||
- {new Date(log.date).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
</h4>
|
||||
<ul className="list-disc list-inside space-y-1 text-gray-700">
|
||||
{log.changes.map((change, index) => (
|
||||
<li key={index}>{change}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Open Source Komponenten */}
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-4">Verwendete Open-Source-Komponenten</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{info.openSourceComponents.frontend && (
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h4 className="font-bold mb-3">Frontend</h4>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{info.openSourceComponents.frontend.map(item => (
|
||||
<li key={item.name}>
|
||||
{item.name} ({item.license}-Lizenz)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{info.openSourceComponents.backend && (
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h4 className="font-bold mb-3">Backend</h4>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{info.openSourceComponents.backend.map(item => (
|
||||
<li key={item.name}>
|
||||
{item.name} ({item.license}-Lizenz)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Programminfo;
|
||||
8
dashboard/src/ressourcen.tsx
Normal file
8
dashboard/src/ressourcen.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
const Ressourcen: React.FC = () => (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-4">Ressourcen</h2>
|
||||
<p>Willkommen im Infoscreen-Management Ressourcen.</p>
|
||||
</div>
|
||||
);
|
||||
export default Ressourcen;
|
||||
4
dashboard/src/types/json.d.ts
vendored
Normal file
4
dashboard/src/types/json.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.json' {
|
||||
const value: unknown;
|
||||
export default value;
|
||||
}
|
||||
1
dashboard/src/vite-env.d.ts
vendored
Normal file
1
dashboard/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
10
dashboard/tailwind.config.cjs
Normal file
10
dashboard/tailwind.config.cjs
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
corePlugins: {
|
||||
preflight: false,
|
||||
},
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
27
dashboard/tsconfig.app.json
Normal file
27
dashboard/tsconfig.app.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
10
dashboard/tsconfig.json
Normal file
10
dashboard/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"typeRoots": ["./src/types", "./node_modules/@types"]
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
25
dashboard/tsconfig.node.json
Normal file
25
dashboard/tsconfig.node.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
54
dashboard/vite.config.ts
Normal file
54
dashboard/vite.config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
// import path from 'path';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
cacheDir: './.vite',
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
// 🔧 KORRIGIERT: Entferne die problematischen Aliases komplett
|
||||
// Diese verursachen das "not an absolute path" Problem
|
||||
// alias: {
|
||||
// '@syncfusion/ej2-react-navigations': '@syncfusion/ej2-react-navigations/index.js',
|
||||
// '@syncfusion/ej2-react-buttons': '@syncfusion/ej2-react-buttons/index.js',
|
||||
// },
|
||||
},
|
||||
optimizeDeps: {
|
||||
// 🔧 NEU: Force pre-bundling der Syncfusion Module
|
||||
include: [
|
||||
'@syncfusion/ej2-react-navigations',
|
||||
'@syncfusion/ej2-react-buttons',
|
||||
'@syncfusion/ej2-base',
|
||||
'@syncfusion/ej2-navigations',
|
||||
'@syncfusion/ej2-buttons',
|
||||
'@syncfusion/ej2-react-base',
|
||||
],
|
||||
// 🔧 NEU: Force dependency re-optimization
|
||||
force: true,
|
||||
esbuildOptions: {
|
||||
target: 'es2020',
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: 'es2020',
|
||||
commonjsOptions: {
|
||||
include: [/node_modules/],
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
watch: {
|
||||
usePolling: true,
|
||||
},
|
||||
fs: {
|
||||
strict: false,
|
||||
},
|
||||
proxy: {
|
||||
'/api': 'http://server:8000',
|
||||
'/screenshots': 'http://server:8000',
|
||||
},
|
||||
},
|
||||
});
|
||||
24
dashboard/wait-for-backend.sh
Executable file
24
dashboard/wait-for-backend.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
# wait-for-backend.sh
|
||||
|
||||
# Stellt sicher, dass das Skript bei einem Fehler abbricht
|
||||
set -e
|
||||
|
||||
# Der erste Parameter ist der Host, der erreicht werden soll
|
||||
host="$1"
|
||||
# Alle weiteren Parameter bilden den Befehl, der danach ausgeführt werden soll
|
||||
shift
|
||||
cmd="$@"
|
||||
|
||||
# Schleife, die so lange läuft, bis der Host mit einem erfolgreichen HTTP-Status antwortet
|
||||
# curl -s: silent mode (kein Fortschrittsbalken)
|
||||
# curl -f: fail silently (gibt einen Fehlercode > 0 zurück, wenn der HTTP-Status nicht 2xx ist)
|
||||
until curl -s -f "$host" > /dev/null; do
|
||||
>&2 echo "Backend ist noch nicht erreichbar - schlafe für 2 Sekunden"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Wenn die Schleife beendet ist, ist das Backend erreichbar
|
||||
>&2 echo "Backend ist erreichbar - starte Vite-Server..."
|
||||
# Führe den eigentlichen Befehl aus (z.B. npm run dev)
|
||||
exec $cmd
|
||||
336
deployment-debian.md
Normal file
336
deployment-debian.md
Normal file
@@ -0,0 +1,336 @@
|
||||
# Infoscreen Deployment Guide (Debian)
|
||||
|
||||
Komplette Anleitung für das Deployment des Infoscreen-Systems auf einem Debian-Server (Bookworm/Trixie) mit GitHub Container Registry.
|
||||
|
||||
## 📋 Übersicht
|
||||
|
||||
- **Phase 0**: Docker Installation (optional)
|
||||
- **Phase 1**: Images bauen und zur Registry pushen
|
||||
- **Phase 2**: Debian-Server Vorbereitung
|
||||
- **Phase 3**: System-Konfiguration und Start
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Phase 0: Docker Installation (optional)
|
||||
|
||||
Falls Docker noch nicht installiert ist, wählen Sie eine der folgenden Optionen:
|
||||
|
||||
### Option A: Debian Repository (schnell, aber oft ältere Version)
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y docker.io docker-compose-plugin
|
||||
sudo systemctl enable docker
|
||||
sudo systemctl start docker
|
||||
```
|
||||
|
||||
### Option B: Offizielle Docker-Installation (empfohlen für Produktion)
|
||||
|
||||
```bash
|
||||
# Alte Docker-Versionen entfernen (falls vorhanden)
|
||||
sudo apt remove -y docker docker-engine docker.io containerd runc
|
||||
|
||||
# Abhängigkeiten installieren
|
||||
sudo apt update
|
||||
sudo apt install -y ca-certificates curl gnupg lsb-release
|
||||
|
||||
# Repository-Schlüssel hinzufügen
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
|
||||
# Debian Codename ermitteln (bookworm / trixie / bullseye ...)
|
||||
source /etc/os-release
|
||||
echo "Using Debian release: $VERSION_CODENAME"
|
||||
|
||||
# Docker Repository hinzufügen
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
|
||||
$VERSION_CODENAME stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
|
||||
# Docker installieren (neueste aus dem offiziellen Repo)
|
||||
sudo apt update
|
||||
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
|
||||
# Docker aktivieren
|
||||
sudo systemctl enable docker
|
||||
sudo systemctl start docker
|
||||
|
||||
# Benutzer zur docker-Gruppe hinzufügen (für Root-losen Zugriff)
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
echo "Bitte neu einloggen (SSH Sitzung beenden und neu verbinden), damit die Gruppenzugehörigkeit aktiv wird."
|
||||
```
|
||||
|
||||
### Docker-Installation testen
|
||||
|
||||
```bash
|
||||
docker run hello-world
|
||||
docker --version
|
||||
docker compose version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Phase 1: Images bauen und pushen (lokale Entwicklungsmaschine)
|
||||
|
||||
### 1. GitHub Container Registry Login
|
||||
|
||||
```bash
|
||||
# Personal Access Token (write:packages) verwenden
|
||||
echo $GITHUB_TOKEN | docker login ghcr.io -u robbstarkaustria --password-stdin
|
||||
|
||||
# Alternativ interaktiv
|
||||
docker login ghcr.io
|
||||
# Username: robbstarkaustria
|
||||
# Password: [GITHUB_TOKEN]
|
||||
```
|
||||
|
||||
### 2. Images bauen und taggen
|
||||
|
||||
```bash
|
||||
cd /workspace
|
||||
|
||||
docker build -f server/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-api:latest .
|
||||
docker build -f dashboard/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-dashboard:latest .
|
||||
docker build -f listener/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-listener:latest .
|
||||
docker build -f scheduler/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-scheduler:latest .
|
||||
```
|
||||
|
||||
### 3. Images pushen
|
||||
|
||||
```bash
|
||||
docker push ghcr.io/robbstarkaustria/infoscreen-api:latest
|
||||
docker push ghcr.io/robbstarkaustria/infoscreen-dashboard:latest
|
||||
docker push ghcr.io/robbstarkaustria/infoscreen-listener:latest
|
||||
docker push ghcr.io/robbstarkaustria/infoscreen-scheduler:latest
|
||||
|
||||
docker images | grep ghcr.io
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Phase 2: Debian-Server Vorbereitung
|
||||
|
||||
### 4. Grundsystem aktualisieren
|
||||
|
||||
```bash
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
sudo apt install -y git curl wget
|
||||
|
||||
# Falls Docker noch fehlt → Phase 0 ausführen
|
||||
```
|
||||
|
||||
### 5. Deployment-Dateien übertragen
|
||||
|
||||
```bash
|
||||
mkdir -p ~/infoscreen-deployment
|
||||
cd ~/infoscreen-deployment
|
||||
|
||||
scp user@dev-machine:/workspace/docker-compose.prod.yml .
|
||||
scp user@dev-machine:/workspace/.env .
|
||||
scp user@dev-machine:/workspace/nginx.conf .
|
||||
scp -r user@dev-machine:/workspace/certs ./
|
||||
scp -r user@dev-machine:/workspace/mosquitto ./
|
||||
|
||||
# Alternative Paketierung:
|
||||
# (auf Entwicklungsrechner)
|
||||
# tar -czf infoscreen-deployment.tar.gz docker-compose.prod.yml .env nginx.conf certs/ mosquitto/
|
||||
# scp infoscreen-deployment.tar.gz user@server:~/
|
||||
# (auf Server)
|
||||
# tar -xzf infoscreen-deployment.tar.gz -C ~/infoscreen-deployment
|
||||
```
|
||||
|
||||
### 6. Mosquitto-Konfiguration (falls nicht kopiert)
|
||||
|
||||
```bash
|
||||
mkdir -p mosquitto/{config,data,log}
|
||||
|
||||
cat > mosquitto/config/mosquitto.conf << 'EOF'
|
||||
listener 1883
|
||||
allow_anonymous true
|
||||
|
||||
listener 9001
|
||||
protocol websockets
|
||||
|
||||
persistence true
|
||||
persistence_location /mosquitto/data/
|
||||
|
||||
log_dest file /mosquitto/log/mosquitto.log
|
||||
EOF
|
||||
|
||||
sudo chown -R 1883:1883 mosquitto/data mosquitto/log
|
||||
chmod 755 mosquitto/config mosquitto/data mosquitto/log
|
||||
```
|
||||
|
||||
### 7. Environment (.env) prüfen/anpassen
|
||||
|
||||
```bash
|
||||
nano .env
|
||||
# Prüfen u.a.:
|
||||
# DB_HOST=db
|
||||
# DB_CONN=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}
|
||||
# VITE_API_URL=https://YOUR_SERVER_HOST/api
|
||||
# Sichere Passwörter & Secrets setzen
|
||||
```
|
||||
|
||||
Hinweise:
|
||||
- Vorlage `.env.example` aus dem Repository verwenden: `cp .env.example .env` (falls noch nicht vorhanden).
|
||||
- In Produktion lädt der Code keine `.env` automatisch (nur bei `ENV=development`).
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Phase 3: System-Start und Konfiguration
|
||||
|
||||
### 8. Images von Registry pullen
|
||||
|
||||
```bash
|
||||
echo $GITHUB_TOKEN | docker login ghcr.io -u robbstarkaustria --password-stdin
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
```
|
||||
|
||||
### 9. System starten
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
docker compose ps
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
### 10. Firewall konfigurieren
|
||||
|
||||
Debian hat standardmäßig nftables/iptables aktiv. Falls eine einfache Verwaltung gewünscht ist, kann `ufw` installiert werden:
|
||||
|
||||
```bash
|
||||
sudo apt install -y ufw
|
||||
sudo ufw allow ssh
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
sudo ufw allow 1883/tcp
|
||||
sudo ufw allow 9001/tcp
|
||||
sudo ufw enable
|
||||
sudo ufw status
|
||||
```
|
||||
|
||||
Alternativ direkt via nftables / iptables konfigurieren (optional, nicht dargestellt).
|
||||
|
||||
### 11. Installation validieren
|
||||
|
||||
```bash
|
||||
curl http://localhost/api/health
|
||||
curl -k https://localhost # -k bei selbstsignierten Zertifikaten
|
||||
|
||||
docker compose ps
|
||||
docker compose logs server
|
||||
docker compose logs mqtt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Quickstart (lokale Entwicklung)
|
||||
|
||||
```bash
|
||||
cp -n .env.example .env
|
||||
docker compose up -d --build
|
||||
docker compose ps
|
||||
docker compose logs -f server
|
||||
```
|
||||
|
||||
Dev-Erreichbarkeit:
|
||||
- Dashboard: http://localhost:5173
|
||||
- API: http://localhost:8000/api
|
||||
- Health: http://localhost:8000/health
|
||||
- Screenshots: http://localhost:8000/screenshots/<uuid>.jpg
|
||||
- MQTT: localhost:1883 (WebSocket: 9001)
|
||||
|
||||
### 12. Systemd Autostart (optional)
|
||||
|
||||
```bash
|
||||
sudo tee /etc/systemd/system/infoscreen.service > /dev/null << 'EOF'
|
||||
[Unit]
|
||||
Description=Infoscreen Application
|
||||
Requires=docker.service
|
||||
After=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=/home/$USER/infoscreen-deployment
|
||||
ExecStart=/usr/bin/docker compose -f docker-compose.prod.yml up -d
|
||||
ExecStop=/usr/bin/docker compose -f docker-compose.prod.yml down
|
||||
TimeoutStartSec=300
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
sudo systemctl enable infoscreen.service
|
||||
sudo systemctl start infoscreen.service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Zugriff auf die Anwendung
|
||||
|
||||
- HTTPS Dashboard: `https://YOUR_SERVER_IP`
|
||||
- HTTP (Redirect): `http://YOUR_SERVER_IP`
|
||||
- API: `http://YOUR_SERVER_IP/api/`
|
||||
- MQTT: `YOUR_SERVER_IP:1883`
|
||||
- MQTT WebSocket: `YOUR_SERVER_IP:9001`
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs -f server
|
||||
docker compose restart server
|
||||
```
|
||||
|
||||
Häufige Ursachen:
|
||||
|
||||
| Problem | Lösung |
|
||||
|---------|--------|
|
||||
| Container startet nicht | `docker compose logs <service>` prüfen |
|
||||
| Ports belegt | `ss -tulpn | grep -E ':80|:443|:1883|:9001'` |
|
||||
| Keine Berechtigung Docker | User zur Gruppe `docker` hinzufügen & neu einloggen |
|
||||
| DB-Verbindung schlägt fehl | `.env` Einträge prüfen (Host = db) |
|
||||
| Mosquitto Fehler | Ordner-Berechtigungen (`1883:1883`) prüfen |
|
||||
|
||||
System Neustart / Update des Stacks:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Wartung
|
||||
|
||||
### Updates
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
```
|
||||
|
||||
### Backup (abhängig von persistenter Datenhaltung – hier nur Mosquitto + Certs exemplarisch)
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
sudo tar -czf infoscreen-backup-$(date +%Y%m%d).tar.gz mosquitto/data/ certs/
|
||||
|
||||
# Wiederherstellung
|
||||
sudo tar -xzf infoscreen-backup-YYYYMMDD.tar.gz
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Das Infoscreen-System ist jetzt auf Ihrem Debian-Server bereitgestellt.**
|
||||
|
||||
Bei Verbesserungswünschen oder Problemen: Issues / Pull Requests im Repository willkommen.
|
||||
417
deployment-ubuntu.md
Normal file
417
deployment-ubuntu.md
Normal file
@@ -0,0 +1,417 @@
|
||||
# Infoscreen Deployment Guide
|
||||
|
||||
Komplette Anleitung für das Deployment des Infoscreen-Systems auf einem Ubuntu-Server mit GitHub Container Registry.
|
||||
|
||||
## 📋 Übersicht
|
||||
|
||||
- **Phase 0**: Docker Installation (optional)
|
||||
- **Phase 1**: Images bauen und zur Registry pushen
|
||||
- **Phase 2**: Ubuntu-Server Installation
|
||||
- **Phase 3**: System-Konfiguration und Start
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Phase 0: Docker Installation (optional)
|
||||
|
||||
Falls Docker noch nicht installiert ist, wählen Sie eine der folgenden Optionen:
|
||||
|
||||
### Option A: Ubuntu Repository (schnell)
|
||||
|
||||
```bash
|
||||
# Standard Ubuntu Docker-Pakete
|
||||
sudo apt update
|
||||
sudo apt install docker.io docker-compose-plugin -y
|
||||
sudo systemctl enable docker
|
||||
sudo systemctl start docker
|
||||
```
|
||||
|
||||
### Option B: Offizielle Docker-Installation (empfohlen)
|
||||
|
||||
```bash
|
||||
# Alte Docker-Versionen entfernen
|
||||
sudo apt remove docker docker-engine docker.io containerd runc -y
|
||||
|
||||
# Abhängigkeiten installieren
|
||||
sudo apt update
|
||||
sudo apt install ca-certificates curl gnupg lsb-release -y
|
||||
|
||||
# Docker GPG-Key hinzufügen
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
|
||||
# Docker Repository hinzufügen
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
|
||||
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
|
||||
# Docker installieren (neueste Version)
|
||||
sudo apt update
|
||||
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y
|
||||
|
||||
# Docker aktivieren und starten
|
||||
sudo systemctl enable docker
|
||||
sudo systemctl start docker
|
||||
|
||||
# User zur Docker-Gruppe hinzufügen
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# Neuanmeldung für Gruppenänderung erforderlich
|
||||
exit
|
||||
# Neu einloggen via SSH
|
||||
```
|
||||
|
||||
### Docker-Installation testen
|
||||
|
||||
```bash
|
||||
# Test-Container ausführen
|
||||
docker run hello-world
|
||||
|
||||
# Docker-Version prüfen
|
||||
docker --version
|
||||
docker compose version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Phase 1: Images bauen und pushen (Entwicklungsmaschine)
|
||||
|
||||
### 1. GitHub Container Registry Login
|
||||
|
||||
```bash
|
||||
# GitHub Personal Access Token mit write:packages Berechtigung erstellen
|
||||
echo $GITHUB_TOKEN | docker login ghcr.io -u robbstarkaustria --password-stdin
|
||||
|
||||
# Oder interaktiv:
|
||||
docker login ghcr.io
|
||||
# Username: robbstarkaustria
|
||||
# Password: [GITHUB_TOKEN]
|
||||
```
|
||||
|
||||
### 2. Images bauen und taggen
|
||||
|
||||
```bash
|
||||
cd /workspace
|
||||
|
||||
# Server-Image bauen
|
||||
docker build -f server/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-api:latest .
|
||||
|
||||
# Dashboard-Image bauen
|
||||
docker build -f dashboard/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-dashboard:latest .
|
||||
|
||||
# Listener-Image bauen (falls vorhanden)
|
||||
docker build -f listener/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-listener:latest .
|
||||
|
||||
# Scheduler-Image bauen (falls vorhanden)
|
||||
docker build -f scheduler/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-scheduler:latest .
|
||||
```
|
||||
|
||||
### 3. Images zur Registry pushen
|
||||
|
||||
```bash
|
||||
# Alle Images pushen
|
||||
docker push ghcr.io/robbstarkaustria/infoscreen-api:latest
|
||||
docker push ghcr.io/robbstarkaustria/infoscreen-dashboard:latest
|
||||
docker push ghcr.io/robbstarkaustria/infoscreen-listener:latest
|
||||
docker push ghcr.io/robbstarkaustria/infoscreen-scheduler:latest
|
||||
|
||||
# Status prüfen
|
||||
docker images | grep ghcr.io
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Phase 2: Ubuntu-Server Installation
|
||||
|
||||
### 4. Ubuntu Server vorbereiten
|
||||
|
||||
```bash
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
|
||||
# Grundlegende Tools installieren
|
||||
sudo apt install git curl wget -y
|
||||
|
||||
# Docker installieren (siehe Phase 0)
|
||||
```
|
||||
|
||||
### 5. Deployment-Dateien übertragen
|
||||
|
||||
```bash
|
||||
# Deployment-Ordner erstellen
|
||||
mkdir -p ~/infoscreen-deployment
|
||||
cd ~/infoscreen-deployment
|
||||
|
||||
# Dateien vom Dev-System kopieren (über SCP)
|
||||
scp user@dev-machine:/workspace/docker-compose.prod.yml .
|
||||
scp user@dev-machine:/workspace/.env .
|
||||
scp user@dev-machine:/workspace/nginx.conf .
|
||||
scp -r user@dev-machine:/workspace/certs ./
|
||||
scp -r user@dev-machine:/workspace/mosquitto ./
|
||||
|
||||
# Alternative: Deployment-Paket verwenden
|
||||
# Auf Dev-Maschine (/workspace):
|
||||
# tar -czf infoscreen-deployment.tar.gz docker-compose.prod.yml .env nginx.conf certs/ mosquitto/
|
||||
# scp infoscreen-deployment.tar.gz user@server:~/
|
||||
# Auf Server: tar -xzf infoscreen-deployment.tar.gz
|
||||
```
|
||||
|
||||
### 6. Mosquitto-Konfiguration vorbereiten
|
||||
|
||||
```bash
|
||||
# Falls mosquitto-Ordner noch nicht vollständig vorhanden:
|
||||
mkdir -p mosquitto/{config,data,log}
|
||||
|
||||
# Mosquitto-Konfiguration erstellen (falls nicht übertragen)
|
||||
cat > mosquitto/config/mosquitto.conf << 'EOF'
|
||||
# -----------------------------
|
||||
# Netzwerkkonfiguration
|
||||
# -----------------------------
|
||||
listener 1883
|
||||
allow_anonymous true
|
||||
# password_file /mosquitto/config/passwd
|
||||
|
||||
# WebSocket (optional)
|
||||
listener 9001
|
||||
protocol websockets
|
||||
|
||||
# -----------------------------
|
||||
# Persistence & Pfade
|
||||
# -----------------------------
|
||||
persistence true
|
||||
persistence_location /mosquitto/data/
|
||||
|
||||
log_dest file /mosquitto/log/mosquitto.log
|
||||
EOF
|
||||
|
||||
# Berechtigungen für Mosquitto setzen
|
||||
sudo chown -R 1883:1883 mosquitto/data mosquitto/log
|
||||
chmod 755 mosquitto/config mosquitto/data mosquitto/log
|
||||
```
|
||||
|
||||
### 7. Environment-Variablen anpassen
|
||||
|
||||
```bash
|
||||
# .env für Produktionsumgebung anpassen
|
||||
nano .env
|
||||
|
||||
# Wichtige Anpassungen:
|
||||
# VITE_API_URL=https://YOUR_SERVER_HOST/api # Für Dashboard-Build (Production)
|
||||
# DB_HOST=db # In Containern immer 'db'
|
||||
# DB_CONN=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}
|
||||
# Alle Passwörter für Produktion ändern
|
||||
```
|
||||
|
||||
Hinweise:
|
||||
- Eine Vorlage `.env.example` liegt im Repo. Kopiere sie als Ausgangspunkt: `cp .env.example .env`.
|
||||
- Für lokale Entwicklung lädt `server/database.py` die `.env`, wenn `ENV=development` gesetzt ist.
|
||||
- In Produktion verwaltet Compose/Container die Variablen; kein automatisches `.env`-Load im Code nötig.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Phase 3: System-Start und Konfiguration
|
||||
|
||||
### 8. Images von Registry pullen
|
||||
|
||||
```bash
|
||||
# GitHub Container Registry Login (falls private Repository)
|
||||
echo $GITHUB_TOKEN | docker login ghcr.io -u robbstarkaustria --password-stdin
|
||||
|
||||
# Images pullen
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
```
|
||||
|
||||
### 9. System starten
|
||||
|
||||
```bash
|
||||
# Container starten
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# Status prüfen
|
||||
docker compose ps
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
### 10. Firewall konfigurieren
|
||||
|
||||
```bash
|
||||
sudo ufw enable
|
||||
sudo ufw allow ssh
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
sudo ufw allow 1883/tcp # MQTT
|
||||
sudo ufw allow 9001/tcp # MQTT WebSocket
|
||||
sudo ufw status
|
||||
```
|
||||
|
||||
### 11. Installation validieren
|
||||
|
||||
```bash
|
||||
# Health-Checks
|
||||
curl http://localhost/api/health
|
||||
curl https://localhost -k # -k für selbstsignierte Zertifikate
|
||||
|
||||
# Container-Status
|
||||
docker compose ps
|
||||
|
||||
# Logs bei Problemen anzeigen
|
||||
docker compose logs server
|
||||
docker compose logs dashboard
|
||||
docker compose logs mqtt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Quickstart (Entwicklung)
|
||||
|
||||
Schneller Start der Entwicklungsumgebung mit automatischen Proxys und Hot-Reload.
|
||||
|
||||
```bash
|
||||
# Im Repository-Root
|
||||
# 1) .env aus Vorlage erzeugen (lokal, falls noch nicht vorhanden)
|
||||
cp -n .env.example .env
|
||||
|
||||
# 2) Dev-Stack starten (verwendet docker-compose.yml + docker-compose.override.yml)
|
||||
docker compose up -d --build
|
||||
|
||||
# 3) Status & Logs
|
||||
docker compose ps
|
||||
docker compose logs -f server
|
||||
docker compose logs -f dashboard
|
||||
docker compose logs -f mqtt
|
||||
|
||||
# 4) Stack stoppen
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Erreichbarkeit (Dev):
|
||||
- Dashboard (Vite): http://localhost:5173
|
||||
- API (Flask Dev): http://localhost:8000/api
|
||||
- API Health: http://localhost:8000/health
|
||||
- Screenshots: http://localhost:8000/screenshots/<uuid>.jpg
|
||||
- MQTT: localhost:1883 (WebSocket: localhost:9001)
|
||||
|
||||
Hinweise:
|
||||
- `ENV=development` lädt `.env` automatisch in `server/database.py`.
|
||||
- Vite proxy routet `/api` und `/screenshots` in Dev direkt auf die API (siehe `dashboard/vite.config.ts`).
|
||||
|
||||
### 12. Automatischer Start (optional)
|
||||
|
||||
```bash
|
||||
# Systemd-Service erstellen
|
||||
sudo tee /etc/systemd/system/infoscreen.service > /dev/null << 'EOF'
|
||||
[Unit]
|
||||
Description=Infoscreen Application
|
||||
Requires=docker.service
|
||||
After=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=/home/$USER/infoscreen-deployment
|
||||
ExecStart=/usr/bin/docker compose -f docker-compose.prod.yml up -d
|
||||
ExecStop=/usr/bin/docker compose -f docker-compose.prod.yml down
|
||||
TimeoutStartSec=300
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# Service aktivieren
|
||||
sudo systemctl enable infoscreen.service
|
||||
sudo systemctl start infoscreen.service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Zugriff auf die Anwendung
|
||||
|
||||
Nach erfolgreichem Deployment ist die Anwendung unter folgenden URLs erreichbar:
|
||||
|
||||
- **HTTPS Dashboard**: `https://YOUR_SERVER_IP`
|
||||
- **HTTP Dashboard**: `http://YOUR_SERVER_IP` (Redirect zu HTTPS)
|
||||
- **API**: `http://YOUR_SERVER_IP/api/`
|
||||
- **MQTT**: `YOUR_SERVER_IP:1883`
|
||||
- **MQTT WebSocket**: `YOUR_SERVER_IP:9001`
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Container-Status prüfen
|
||||
|
||||
```bash
|
||||
# Alle Container anzeigen
|
||||
docker compose ps
|
||||
|
||||
# Spezifische Logs anzeigen
|
||||
docker compose logs -f [service-name]
|
||||
|
||||
# Container einzeln neustarten
|
||||
docker compose restart [service-name]
|
||||
```
|
||||
|
||||
### System neustarten
|
||||
|
||||
```bash
|
||||
# Komplett neu starten
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
|
||||
# Images neu pullen
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Häufige Probleme
|
||||
|
||||
| Problem | Lösung |
|
||||
|---------|--------|
|
||||
| Container startet nicht | `docker compose logs [service]` prüfen |
|
||||
| Ports bereits belegt | `sudo netstat -tulpn \| grep :80` prüfen |
|
||||
| Keine Berechtigung | User zu docker-Gruppe hinzufügen |
|
||||
| DB-Verbindung fehlschlägt | Environment-Variablen in `.env` prüfen |
|
||||
| Mosquitto startet nicht | Ordner-Berechtigungen für `1883:1883` setzen |
|
||||
|
||||
---
|
||||
|
||||
## 📊 Docker-Version Vergleich
|
||||
|
||||
| Aspekt | Ubuntu Repository | Offizielle Installation |
|
||||
|--------|------------------|------------------------|
|
||||
| **Installation** | ✅ Schnell (1 Befehl) | ⚠️ Mehrere Schritte |
|
||||
| **Version** | ⚠️ Oft älter | ✅ Neueste Version |
|
||||
| **Updates** | ✅ Via apt | ✅ Via apt (nach Setup) |
|
||||
| **Stabilität** | ✅ Getestet | ✅ Aktuell |
|
||||
| **Features** | ⚠️ Möglicherweise eingeschränkt | ✅ Alle Features |
|
||||
|
||||
**Empfehlung:** Für Produktion die offizielle Docker-Installation verwenden.
|
||||
|
||||
---
|
||||
|
||||
## 📝 Wartung
|
||||
|
||||
### Regelmäßige Updates
|
||||
|
||||
```bash
|
||||
# Images aktualisieren
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
# System-Updates
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
```
|
||||
|
||||
### Backup
|
||||
|
||||
```bash
|
||||
# Container-Daten sichern
|
||||
docker compose down
|
||||
sudo tar -czf infoscreen-backup-$(date +%Y%m%d).tar.gz mosquitto/data/ certs/
|
||||
|
||||
# Backup wiederherstellen
|
||||
sudo tar -xzf infoscreen-backup-YYYYMMDD.tar.gz
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Das Infoscreen-System ist jetzt vollständig über GitHub
|
||||
143
docker-compose.prod.yml
Normal file
143
docker-compose.prod.yml
Normal file
@@ -0,0 +1,143 @@
|
||||
networks:
|
||||
infoscreen-net:
|
||||
driver: bridge
|
||||
|
||||
services:
|
||||
proxy:
|
||||
image: nginx:1.25
|
||||
container_name: infoscreen-proxy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./certs:/etc/nginx/certs:ro
|
||||
depends_on:
|
||||
- server
|
||||
- dashboard
|
||||
networks:
|
||||
- infoscreen-net
|
||||
|
||||
db:
|
||||
image: mariadb:11.2
|
||||
container_name: infoscreen-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: ${DB_NAME}
|
||||
MYSQL_USER: ${DB_USER}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- db-data:/var/lib/mysql
|
||||
networks:
|
||||
- infoscreen-net
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
mqtt:
|
||||
image: eclipse-mosquitto:2.0.21
|
||||
container_name: infoscreen-mqtt
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./mosquitto/config/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
|
||||
ports:
|
||||
- "1883:1883"
|
||||
- "9001:9001"
|
||||
networks:
|
||||
- infoscreen-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mosquitto_pub -h localhost -t test -m 'health' || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# Verwende fertige Images statt Build
|
||||
server:
|
||||
image: ghcr.io/robbstarkaustria/infoscreen-api:latest
|
||||
container_name: infoscreen-api
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
mqtt:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DB_CONN: "mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}"
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
DB_NAME: ${DB_NAME}
|
||||
DB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||
DB_HOST: db
|
||||
FLASK_ENV: production
|
||||
MQTT_BROKER_URL: mqtt://mqtt:1883
|
||||
MQTT_USER: ${MQTT_USER}
|
||||
MQTT_PASSWORD: ${MQTT_PASSWORD}
|
||||
networks:
|
||||
- infoscreen-net
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
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"
|
||||
|
||||
dashboard:
|
||||
image: ghcr.io/robbstarkaustria/infoscreen-dashboard:latest # Oder wo auch immer Ihre Images liegen
|
||||
container_name: infoscreen-dashboard
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
VITE_API_URL: ${API_URL}
|
||||
networks:
|
||||
- infoscreen-net
|
||||
|
||||
listener:
|
||||
image: ghcr.io/robbstarkaustria/infoscreen-listener:latest # Oder wo auch immer Ihre Images liegen
|
||||
container_name: infoscreen-listener
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
mqtt:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DB_CONN: "mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}"
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
DB_NAME: ${DB_NAME}
|
||||
DB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||
networks:
|
||||
- infoscreen-net
|
||||
|
||||
scheduler:
|
||||
image: ghcr.io/robbstarkaustria/infoscreen-scheduler:latest
|
||||
container_name: infoscreen-scheduler
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
# HINZUGEFÜGT: Stellt sicher, dass die DB vor dem Scheduler startet
|
||||
db:
|
||||
condition: service_healthy
|
||||
mqtt:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# HINZUGEFÜGT: Datenbank-Verbindungsstring
|
||||
DB_CONN: "mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}"
|
||||
MQTT_PORT: 1883
|
||||
networks:
|
||||
- infoscreen-net
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
209
docker-compose.yml
Normal file
209
docker-compose.yml
Normal file
@@ -0,0 +1,209 @@
|
||||
networks:
|
||||
infoscreen-net:
|
||||
driver: bridge
|
||||
|
||||
services:
|
||||
listener:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: listener/Dockerfile
|
||||
image: infoscreen-listener:latest
|
||||
container_name: infoscreen-listener
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
mqtt:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- DB_CONN=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}
|
||||
- DB_URL=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}
|
||||
# 🔧 ENTFERNT: Volume-Mount ist nur für die Entwicklung
|
||||
networks:
|
||||
- infoscreen-net
|
||||
|
||||
proxy:
|
||||
image: nginx:1.25 # 🔧 GEÄNDERT: Spezifische Version
|
||||
container_name: infoscreen-proxy
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro # 🔧 GEÄNDERT: Relativer Pfad
|
||||
- ./certs:/etc/nginx/certs:ro # 🔧 GEÄNDERT: Relativer Pfad
|
||||
depends_on:
|
||||
- server
|
||||
- dashboard
|
||||
networks:
|
||||
- infoscreen-net
|
||||
|
||||
db:
|
||||
image: mariadb:11.2 # 🔧 GEÄNDERT: Spezifische Version
|
||||
container_name: infoscreen-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: ${DB_NAME}
|
||||
MYSQL_USER: ${DB_USER}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- db-data:/var/lib/mysql
|
||||
ports:
|
||||
- "3306:3306"
|
||||
networks:
|
||||
- infoscreen-net
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
mqtt:
|
||||
image: eclipse-mosquitto:2.0.21 # ✅ GUT: Version ist bereits spezifisch
|
||||
container_name: infoscreen-mqtt
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./mosquitto/config:/mosquitto/config
|
||||
- ./mosquitto/data:/mosquitto/data
|
||||
- ./mosquitto/log:/mosquitto/log
|
||||
ports:
|
||||
- "1883:1883" # Standard MQTT
|
||||
- "9001:9001" # WebSocket (falls benötigt)
|
||||
networks:
|
||||
- infoscreen-net
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"mosquitto_pub -h localhost -t test -m 'health' || exit 1",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: server/Dockerfile
|
||||
image: infoscreen-api:latest
|
||||
container_name: infoscreen-api
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
mqtt:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DB_CONN: "mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}"
|
||||
FLASK_ENV: ${FLASK_ENV}
|
||||
ENV_FILE: ${ENV_FILE}
|
||||
MQTT_BROKER_URL: ${MQTT_BROKER_URL}
|
||||
MQTT_USER: ${MQTT_USER}
|
||||
MQTT_PASSWORD: ${MQTT_PASSWORD}
|
||||
REDIS_URL: "${REDIS_URL:-redis://redis:6379/0}"
|
||||
GOTENBERG_URL: "${GOTENBERG_URL:-http://gotenberg:3000}"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
networks:
|
||||
- infoscreen-net
|
||||
volumes:
|
||||
- media-data:/app/server/media
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# ✅ GEÄNDERT: Dashboard jetzt mit Node.js/React statt Python/Dash
|
||||
dashboard:
|
||||
build:
|
||||
context: ./dashboard
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- VITE_API_URL=${API_URL}
|
||||
image: infoscreen-dashboard:latest
|
||||
container_name: infoscreen-dashboard
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- VITE_API_URL=${API_URL}
|
||||
# 🔧 ENTFERNT: Port wird in Produktion nicht direkt freigegeben, Zugriff via Proxy
|
||||
networks:
|
||||
- infoscreen-net
|
||||
healthcheck:
|
||||
# 🔧 GEÄNDERT: Healthcheck prüft den Nginx-Server im Container
|
||||
test: ["CMD", "curl", "-f", "http://localhost/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
# 🔧 ERHÖHT: Gibt dem Backend mehr Zeit zum Starten, bevor dieser
|
||||
# Container als "gesund" markiert wird.
|
||||
start_period: 60s
|
||||
|
||||
scheduler:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: scheduler/Dockerfile
|
||||
image: infoscreen-scheduler:latest
|
||||
container_name: infoscreen-scheduler
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
# HINZUGEFÜGT: Stellt sicher, dass die DB vor dem Scheduler startet
|
||||
db:
|
||||
condition: service_healthy
|
||||
mqtt:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# HINZUGEFÜGT: Datenbank-Verbindungsstring
|
||||
- DB_CONN=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}
|
||||
- MQTT_PORT=1883
|
||||
networks:
|
||||
- infoscreen-net
|
||||
|
||||
volumes:
|
||||
- ./scheduler:/app/scheduler
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: infoscreen-redis
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- infoscreen-net
|
||||
|
||||
gotenberg:
|
||||
image: gotenberg/gotenberg:8
|
||||
container_name: infoscreen-gotenberg
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- infoscreen-net
|
||||
|
||||
worker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: server/Dockerfile
|
||||
image: infoscreen-worker:latest
|
||||
container_name: infoscreen-worker
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- redis
|
||||
- gotenberg
|
||||
- db
|
||||
environment:
|
||||
DB_CONN: "mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}"
|
||||
REDIS_URL: "${REDIS_URL:-redis://redis:6379/0}"
|
||||
GOTENBERG_URL: "${GOTENBERG_URL:-http://gotenberg:3000}"
|
||||
PYTHONPATH: /app
|
||||
command: ["rq", "worker", "conversions"]
|
||||
networks:
|
||||
- infoscreen-net
|
||||
|
||||
volumes:
|
||||
server-pip-cache:
|
||||
db-data:
|
||||
media-data:
|
||||
100
early-validation.sh
Normal file
100
early-validation.sh
Normal file
@@ -0,0 +1,100 @@
|
||||
#!/bin/bash
|
||||
# Early Hardware Validation für 25% Entwicklungsstand
|
||||
# Ziel: Architektur-Probleme früh erkennen, nicht Volltest
|
||||
|
||||
echo "🧪 Infoscreen Early Hardware Validation"
|
||||
echo "======================================"
|
||||
echo "Entwicklungsstand: ~25-30%"
|
||||
echo "Ziel: Basis-Deployment + Performance-Baseline"
|
||||
echo ""
|
||||
|
||||
# Phase 1: Quick-Setup (30 Min)
|
||||
echo "📦 Phase 1: Container-Setup-Test"
|
||||
echo "- Docker-Compose startet alle Services?"
|
||||
echo "- Health-Checks werden grün?"
|
||||
echo "- Ports sind erreichbar?"
|
||||
echo ""
|
||||
|
||||
# Phase 2: Connectivity-Test (1 Stunde)
|
||||
echo "🌐 Phase 2: Service-Kommunikation"
|
||||
echo "- Database-Connection vom Server?"
|
||||
echo "- MQTT-Broker empfängt Messages?"
|
||||
echo "- Nginx routet zu Services?"
|
||||
echo "- API-Grundendpoints antworten?"
|
||||
echo ""
|
||||
|
||||
# Phase 3: Performance-Baseline (2 Stunden)
|
||||
echo "📊 Phase 3: Performance-Snapshot"
|
||||
echo "- Memory-Verbrauch pro Container"
|
||||
echo "- CPU-Usage im Idle"
|
||||
echo "- Startup-Zeiten messen"
|
||||
echo "- Network-Latency zwischen Services"
|
||||
echo ""
|
||||
|
||||
# Phase 4: Basic Load-Test (4 Stunden)
|
||||
echo "🔥 Phase 4: Basis-Belastungstest"
|
||||
echo "- 10 parallele API-Requests"
|
||||
echo "- 1000 MQTT-Messages senden"
|
||||
echo "- Database-Insert-Performance"
|
||||
echo "- Memory-Leak-Check (1h Laufzeit)"
|
||||
echo ""
|
||||
|
||||
# Test-Checklist erstellen
|
||||
cat > early-validation-checklist.md << 'EOF'
|
||||
# Early Hardware Validation Checklist
|
||||
|
||||
## ✅ Container-Setup
|
||||
- [ ] `docker compose up -d` erfolgreich
|
||||
- [ ] Alle Services zeigen "healthy" Status
|
||||
- [ ] Keine Error-Logs in den ersten 5 Minuten
|
||||
- [ ] Ports 80, 8000, 3306, 1883 erreichbar
|
||||
|
||||
## ✅ Service-Kommunikation
|
||||
- [ ] Server kann zu Database verbinden
|
||||
- [ ] MQTT-Test-Message wird empfangen
|
||||
- [ ] Nginx zeigt Service-Status-Page
|
||||
- [ ] API-Health-Endpoint antwortet (200 OK)
|
||||
|
||||
## ✅ Performance-Baseline
|
||||
- [ ] Total Memory < 4GB bei Idle
|
||||
- [ ] CPU-Usage < 10% bei Idle
|
||||
- [ ] Container-Startup < 60s
|
||||
- [ ] API-Response-Time < 500ms
|
||||
|
||||
## ✅ Basic-Load-Test
|
||||
- [ ] 10 parallele Requests ohne Errors
|
||||
- [ ] 1000 MQTT-Messages ohne Message-Loss
|
||||
- [ ] Memory-Usage stabil über 1h
|
||||
- [ ] Keine Container-Restarts
|
||||
|
||||
## 📊 Baseline-Metriken (dokumentieren)
|
||||
- Memory pro Container: ___MB
|
||||
- CPU-Usage bei Load: ___%
|
||||
- API-Response-Time: ___ms
|
||||
- Database-Query-Time: ___ms
|
||||
- Container-Startup-Zeit: ___s
|
||||
|
||||
## 🚨 Gefundene Probleme
|
||||
- [ ] Performance-Bottlenecks: ____________
|
||||
- [ ] Memory-Issues: ____________________
|
||||
- [ ] Network-Probleme: _________________
|
||||
- [ ] Container-Probleme: _______________
|
||||
|
||||
## ✅ Architektur-Validierung
|
||||
- [ ] Container-Orchestrierung funktioniert
|
||||
- [ ] Service-Discovery läuft
|
||||
- [ ] Volume-Mounting korrekt
|
||||
- [ ] Environment-Variables werden geladen
|
||||
- [ ] Health-Checks sind aussagekräftig
|
||||
EOF
|
||||
|
||||
echo "✅ Early Validation Checklist erstellt: early-validation-checklist.md"
|
||||
echo ""
|
||||
echo "🎯 Erwartetes Ergebnis:"
|
||||
echo "- Architektur-Probleme identifiziert"
|
||||
echo "- Performance-Baseline dokumentiert"
|
||||
echo "- Deployment-Prozess validiert"
|
||||
echo "- Basis für spätere Tests gelegt"
|
||||
echo ""
|
||||
echo "⏰ Geschätzter Aufwand: 8-12 Stunden über 2-3 Tage"
|
||||
echo "💰 ROI: Verhindert teure Architektur-Änderungen später"
|
||||
0
entrypoint.sh
Normal file
0
entrypoint.sh
Normal file
140
hardware-test-setup.sh
Normal file
140
hardware-test-setup.sh
Normal file
@@ -0,0 +1,140 @@
|
||||
#!/bin/bash
|
||||
# Infoscreen Hardware Test Setup für Quad-Core 16GB System
|
||||
|
||||
echo "🖥️ Infoscreen Hardware Test Setup"
|
||||
echo "=================================="
|
||||
echo "System: Quad-Core, 16GB RAM, SSD"
|
||||
echo ""
|
||||
|
||||
# System-Info anzeigen
|
||||
echo "📊 System-Information:"
|
||||
echo "CPU Cores: $(nproc)"
|
||||
echo "RAM Total: $(free -h | grep Mem | awk '{print $2}')"
|
||||
echo "Disk Free: $(df -h / | tail -1 | awk '{print $4}')"
|
||||
echo ""
|
||||
|
||||
# Docker-Setup
|
||||
echo "🐳 Docker-Setup..."
|
||||
sudo apt update -y
|
||||
sudo apt install -y docker.io docker-compose-plugin
|
||||
sudo systemctl enable docker
|
||||
sudo systemctl start docker
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# Test-Verzeichnisse erstellen
|
||||
echo "📁 Test-Umgebung erstellen..."
|
||||
mkdir -p ~/infoscreen-hardware-test/{prod,dev,monitoring,scripts,backups}
|
||||
|
||||
# Performance-Monitoring-Tools
|
||||
echo "📊 Monitoring-Tools installieren..."
|
||||
sudo apt install -y htop iotop nethogs ncdu stress-ng
|
||||
|
||||
# Test-Script erstellen
|
||||
cat > ~/infoscreen-hardware-test/scripts/system-monitor.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# System-Monitoring während Tests
|
||||
|
||||
echo "=== Infoscreen System Monitor ==="
|
||||
echo "Zeit: $(date)"
|
||||
echo ""
|
||||
|
||||
echo "🖥️ CPU-Info:"
|
||||
echo "Load: $(uptime | awk -F'load average:' '{print $2}')"
|
||||
echo "Cores: $(nproc) | Usage: $(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | cut -d'%' -f1)%"
|
||||
|
||||
echo ""
|
||||
echo "💾 Memory-Info:"
|
||||
free -h
|
||||
|
||||
echo ""
|
||||
echo "💿 Disk-Info:"
|
||||
df -h /
|
||||
|
||||
echo ""
|
||||
echo "🐳 Docker-Info:"
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||
|
||||
echo ""
|
||||
echo "🌡️ System-Temperature (falls verfügbar):"
|
||||
sensors 2>/dev/null || echo "lm-sensors nicht installiert"
|
||||
|
||||
echo ""
|
||||
echo "🌐 Network-Connections:"
|
||||
ss -tuln | grep :80\\\|:443\\\|:8000\\\|:3306\\\|:1883
|
||||
EOF
|
||||
|
||||
chmod +x ~/infoscreen-hardware-test/scripts/system-monitor.sh
|
||||
|
||||
# Load-Test-Script erstellen
|
||||
cat > ~/infoscreen-hardware-test/scripts/load-test.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# Load-Test für Infoscreen-System
|
||||
|
||||
echo "🔥 Infoscreen Load-Test startet..."
|
||||
|
||||
# CPU-Load erzeugen (für Thermal-Tests)
|
||||
echo "CPU-Stress-Test (30s)..."
|
||||
stress-ng --cpu $(nproc) --timeout 30s &
|
||||
|
||||
# Memory-Test
|
||||
echo "Memory-Stress-Test..."
|
||||
stress-ng --vm 2 --vm-bytes 2G --timeout 30s &
|
||||
|
||||
# Disk-I/O-Test
|
||||
echo "Disk-I/O-Test..."
|
||||
stress-ng --hdd 1 --hdd-bytes 1G --timeout 30s &
|
||||
|
||||
# Warten auf Tests
|
||||
wait
|
||||
|
||||
echo "✅ Load-Test abgeschlossen"
|
||||
EOF
|
||||
|
||||
chmod +x ~/infoscreen-hardware-test/scripts/load-test.sh
|
||||
|
||||
# Docker-Test-Setup
|
||||
echo "🧪 Docker-Test-Setup..."
|
||||
cat > ~/infoscreen-hardware-test/docker-compose.test.yml << 'EOF'
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
test-web:
|
||||
image: nginx:alpine
|
||||
ports: ["8080:80"]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
reservations:
|
||||
memory: 128M
|
||||
|
||||
test-db:
|
||||
image: mariadb:11.2
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: test123
|
||||
MYSQL_DATABASE: testdb
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
reservations:
|
||||
memory: 256M
|
||||
|
||||
test-load:
|
||||
image: alpine
|
||||
command: sh -c "while true; do wget -q -O- http://test-web/ > /dev/null; sleep 0.1; done"
|
||||
depends_on: [test-web]
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "✅ Setup abgeschlossen!"
|
||||
echo ""
|
||||
echo "🚀 Nächste Schritte:"
|
||||
echo "1. Logout/Login für Docker-Gruppe"
|
||||
echo "2. Test: docker run hello-world"
|
||||
echo "3. System-Monitor: ~/infoscreen-hardware-test/scripts/system-monitor.sh"
|
||||
echo "4. Load-Test: ~/infoscreen-hardware-test/scripts/load-test.sh"
|
||||
echo "5. Docker-Test: cd ~/infoscreen-hardware-test && docker compose -f docker-compose.test.yml up"
|
||||
echo ""
|
||||
echo "📁 Test-Verzeichnis: ~/infoscreen-hardware-test/"
|
||||
echo "📊 Monitoring: Führen Sie system-monitor.sh parallel zu Tests aus"
|
||||
0
helpers/__init__.py
Normal file
0
helpers/__init__.py
Normal file
56
helpers/check_folder.py
Normal file
56
helpers/check_folder.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
def ensure_folder_exists(folder_path):
|
||||
"""
|
||||
Check if a folder exists and create it if it doesn't.
|
||||
|
||||
Args:
|
||||
folder_path (str or Path): Path to the folder to check/create
|
||||
|
||||
Returns:
|
||||
bool: True if folder was created, False if it already existed
|
||||
|
||||
Raises:
|
||||
OSError: If folder creation fails due to permissions or other issues
|
||||
"""
|
||||
folder_path = Path(folder_path)
|
||||
|
||||
if folder_path.exists():
|
||||
if folder_path.is_dir():
|
||||
return False # Folder already exists
|
||||
else:
|
||||
raise OSError(f"Path '{folder_path}' exists but is not a directory")
|
||||
|
||||
try:
|
||||
folder_path.mkdir(parents=True, exist_ok=True)
|
||||
return True # Folder was created
|
||||
except OSError as e:
|
||||
raise OSError(f"Failed to create folder '{folder_path}': {e}")
|
||||
|
||||
# Alternative simpler version using os module
|
||||
def ensure_folder_exists_simple(folder_path):
|
||||
"""
|
||||
Simple version using os.makedirs with exist_ok parameter.
|
||||
|
||||
Args:
|
||||
folder_path (str): Path to the folder to check/create
|
||||
"""
|
||||
os.makedirs(folder_path, exist_ok=True)
|
||||
|
||||
# Usage examples
|
||||
if __name__ == "__main__":
|
||||
# Example 1: Create a single folder
|
||||
folder_created = ensure_folder_exists("my_new_folder")
|
||||
print(f"Folder created: {folder_created}")
|
||||
|
||||
# Example 2: Create nested folders
|
||||
ensure_folder_exists("data/processed/results")
|
||||
|
||||
# Example 3: Using the simple version
|
||||
ensure_folder_exists_simple("logs/2024")
|
||||
|
||||
# Example 4: Using with absolute path
|
||||
import tempfile
|
||||
temp_dir = tempfile.gettempdir()
|
||||
ensure_folder_exists(os.path.join(temp_dir, "my_app", "cache"))
|
||||
4
listener/.dockerignore
Normal file
4
listener/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.log
|
||||
16
listener/Dockerfile
Normal file
16
listener/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
# Listener Dockerfile
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY listener/requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Mosquitto-Tools für MQTT-Tests installieren
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends mosquitto-clients && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY listener/ ./listener
|
||||
COPY models/ ./models
|
||||
|
||||
ENV PYTHONPATH=/app
|
||||
CMD ["python", "listener/listener.py"]
|
||||
102
listener/listener.py
Normal file
102
listener/listener.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import datetime
|
||||
import paho.mqtt.client as mqtt
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from models.models import Client
|
||||
|
||||
if os.getenv("ENV", "development") == "development":
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(".env")
|
||||
|
||||
# ENV-abhängige Konfiguration
|
||||
ENV = os.getenv("ENV", "development")
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO" if ENV == "production" else "DEBUG")
|
||||
DB_URL = os.environ.get(
|
||||
"DB_CONN", "mysql+pymysql://user:password@db/infoscreen")
|
||||
|
||||
# Logging
|
||||
logging.basicConfig(level=logging.DEBUG,
|
||||
format='%(asctime)s [%(levelname)s] %(message)s')
|
||||
|
||||
# DB-Konfiguration
|
||||
engine = create_engine(DB_URL)
|
||||
Session = sessionmaker(bind=engine)
|
||||
|
||||
|
||||
def on_message(client, userdata, msg):
|
||||
topic = msg.topic
|
||||
logging.debug(f"Empfangene Nachricht auf Topic: {topic}")
|
||||
|
||||
try:
|
||||
# Heartbeat-Handling
|
||||
if topic.startswith("infoscreen/") and topic.endswith("/heartbeat"):
|
||||
uuid = topic.split("/")[1]
|
||||
session = Session()
|
||||
client_obj = session.query(Client).filter_by(uuid=uuid).first()
|
||||
if client_obj:
|
||||
client_obj.last_alive = datetime.datetime.now(datetime.UTC)
|
||||
session.commit()
|
||||
logging.info(
|
||||
f"Heartbeat von {uuid} empfangen, last_alive (UTC) aktualisiert.")
|
||||
session.close()
|
||||
return
|
||||
|
||||
# Discovery-Handling
|
||||
if topic == "infoscreen/discovery":
|
||||
payload = json.loads(msg.payload.decode())
|
||||
logging.info(f"Discovery empfangen: {payload}")
|
||||
|
||||
if "uuid" in payload:
|
||||
uuid = payload["uuid"]
|
||||
session = Session()
|
||||
existing = session.query(Client).filter_by(uuid=uuid).first()
|
||||
|
||||
if not existing:
|
||||
new_client = Client(
|
||||
uuid=uuid,
|
||||
hardware_token=payload.get("hardware_token"),
|
||||
ip=payload.get("ip"),
|
||||
type=payload.get("type"),
|
||||
hostname=payload.get("hostname"),
|
||||
os_version=payload.get("os_version"),
|
||||
software_version=payload.get("software_version"),
|
||||
macs=",".join(payload.get("macs", [])),
|
||||
model=payload.get("model"),
|
||||
registration_time=datetime.datetime.now(datetime.UTC),
|
||||
)
|
||||
session.add(new_client)
|
||||
session.commit()
|
||||
logging.info(f"Neuer Client registriert: {uuid}")
|
||||
else:
|
||||
logging.info(f"Client bereits bekannt: {uuid}")
|
||||
|
||||
session.close()
|
||||
|
||||
# Discovery-ACK senden
|
||||
ack_topic = f"infoscreen/{uuid}/discovery_ack"
|
||||
client.publish(ack_topic, json.dumps({"status": "ok"}))
|
||||
logging.info(f"Discovery-ACK gesendet an {ack_topic}")
|
||||
else:
|
||||
logging.warning("Discovery ohne UUID empfangen, ignoriert.")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler bei Verarbeitung: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
mqtt_client = mqtt.Client(protocol=mqtt.MQTTv311, callback_api_version=2)
|
||||
mqtt_client.on_message = on_message
|
||||
mqtt_client.connect("mqtt", 1883)
|
||||
mqtt_client.subscribe("infoscreen/discovery")
|
||||
mqtt_client.subscribe("infoscreen/+/heartbeat")
|
||||
|
||||
logging.info(
|
||||
"Listener gestartet und abonniert auf infoscreen/discovery und infoscreen/+/heartbeat")
|
||||
mqtt_client.loop_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
4
listener/requirements.txt
Normal file
4
listener/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
paho-mqtt>=2.0
|
||||
SQLAlchemy>=2.0
|
||||
pymysql
|
||||
python-dotenv
|
||||
1
models/__init__.py
Normal file
1
models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# models package for shared SQLAlchemy models
|
||||
271
models/models.py
Normal file
271
models/models.py
Normal file
@@ -0,0 +1,271 @@
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, Enum, TIMESTAMP, func, Boolean, ForeignKey, Float, Text, Index, DateTime, Date, UniqueConstraint
|
||||
)
|
||||
from sqlalchemy.orm import declarative_base, relationship
|
||||
import enum
|
||||
from datetime import datetime, timezone
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class UserRole(enum.Enum):
|
||||
user = "user"
|
||||
admin = "admin"
|
||||
superadmin = "superadmin"
|
||||
|
||||
|
||||
class AcademicPeriodType(enum.Enum):
|
||||
schuljahr = "schuljahr"
|
||||
semester = "semester"
|
||||
trimester = "trimester"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = 'users'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
username = Column(String(50), unique=True, nullable=False, index=True)
|
||||
password_hash = Column(String(128), nullable=False)
|
||||
role = Column(Enum(UserRole), nullable=False, default=UserRole.user)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(TIMESTAMP(timezone=True),
|
||||
server_default=func.current_timestamp())
|
||||
updated_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp(
|
||||
), onupdate=func.current_timestamp())
|
||||
|
||||
|
||||
class AcademicPeriod(Base):
|
||||
__tablename__ = 'academic_periods'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False) # "Schuljahr 2024/25"
|
||||
display_name = Column(String(50), nullable=True) # "SJ 24/25" (kurz)
|
||||
start_date = Column(Date, nullable=False, index=True)
|
||||
end_date = Column(Date, nullable=False, index=True)
|
||||
period_type = Column(Enum(AcademicPeriodType),
|
||||
nullable=False, default=AcademicPeriodType.schuljahr)
|
||||
# nur eine aktive Periode zur Zeit
|
||||
is_active = Column(Boolean, default=False, nullable=False)
|
||||
created_at = Column(TIMESTAMP(timezone=True),
|
||||
server_default=func.current_timestamp())
|
||||
updated_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp(
|
||||
), onupdate=func.current_timestamp())
|
||||
|
||||
# Constraint: nur eine aktive Periode zur Zeit
|
||||
__table_args__ = (
|
||||
Index('ix_academic_periods_active', 'is_active'),
|
||||
UniqueConstraint('name', name='uq_academic_periods_name'),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"display_name": self.display_name,
|
||||
"start_date": self.start_date.isoformat() if self.start_date else None,
|
||||
"end_date": self.end_date.isoformat() if self.end_date else None,
|
||||
"period_type": self.period_type.value if self.period_type else None,
|
||||
"is_active": self.is_active,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class ClientGroup(Base):
|
||||
__tablename__ = 'client_groups'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), unique=True, nullable=False)
|
||||
description = Column(String(255), nullable=True) # Manuell zu setzen
|
||||
created_at = Column(TIMESTAMP(timezone=True),
|
||||
server_default=func.current_timestamp())
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
|
||||
class Client(Base):
|
||||
__tablename__ = 'clients'
|
||||
uuid = Column(String(36), primary_key=True, nullable=False)
|
||||
hardware_token = Column(String(64), nullable=True)
|
||||
ip = Column(String(45), nullable=True)
|
||||
type = Column(String(50), nullable=True)
|
||||
hostname = Column(String(100), nullable=True)
|
||||
os_version = Column(String(100), nullable=True)
|
||||
software_version = Column(String(100), nullable=True)
|
||||
macs = Column(String(255), nullable=True)
|
||||
model = Column(String(100), nullable=True)
|
||||
description = Column(String(255), nullable=True) # Manuell zu setzen
|
||||
registration_time = Column(TIMESTAMP(
|
||||
timezone=True), server_default=func.current_timestamp(), nullable=False)
|
||||
last_alive = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp(
|
||||
), onupdate=func.current_timestamp(), nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
group_id = Column(Integer, ForeignKey(
|
||||
'client_groups.id'), nullable=False, default=1)
|
||||
|
||||
|
||||
class EventType(enum.Enum):
|
||||
presentation = "presentation"
|
||||
website = "website"
|
||||
video = "video"
|
||||
message = "message"
|
||||
other = "other"
|
||||
webuntis = "webuntis"
|
||||
|
||||
|
||||
class MediaType(enum.Enum):
|
||||
# Präsentationen
|
||||
pdf = "pdf"
|
||||
ppt = "ppt"
|
||||
pptx = "pptx"
|
||||
odp = "odp"
|
||||
# Videos (gängige VLC-Formate)
|
||||
mp4 = "mp4"
|
||||
avi = "avi"
|
||||
mkv = "mkv"
|
||||
mov = "mov"
|
||||
wmv = "wmv"
|
||||
flv = "flv"
|
||||
webm = "webm"
|
||||
mpg = "mpg"
|
||||
mpeg = "mpeg"
|
||||
ogv = "ogv"
|
||||
# Bilder (benutzerfreundlich)
|
||||
jpg = "jpg"
|
||||
jpeg = "jpeg"
|
||||
png = "png"
|
||||
gif = "gif"
|
||||
bmp = "bmp"
|
||||
tiff = "tiff"
|
||||
svg = "svg"
|
||||
# HTML-Mitteilung
|
||||
html = "html"
|
||||
# Webseiten
|
||||
website = "website"
|
||||
|
||||
|
||||
class Event(Base):
|
||||
__tablename__ = 'events'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
group_id = Column(Integer, ForeignKey(
|
||||
'client_groups.id'), nullable=False, index=True)
|
||||
academic_period_id = Column(Integer, ForeignKey(
|
||||
# Optional für Rückwärtskompatibilität
|
||||
'academic_periods.id'), nullable=True, index=True)
|
||||
title = Column(String(100), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
start = Column(TIMESTAMP(timezone=True), nullable=False, index=True)
|
||||
end = Column(TIMESTAMP(timezone=True), nullable=False, index=True)
|
||||
event_type = Column(Enum(EventType), nullable=False)
|
||||
event_media_id = Column(Integer, ForeignKey(
|
||||
'event_media.id'), nullable=True)
|
||||
autoplay = Column(Boolean, nullable=True) # NEU
|
||||
loop = Column(Boolean, nullable=True) # NEU
|
||||
volume = Column(Float, nullable=True) # NEU
|
||||
slideshow_interval = Column(Integer, nullable=True) # NEU
|
||||
created_at = Column(TIMESTAMP(timezone=True),
|
||||
server_default=func.current_timestamp())
|
||||
updated_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp(
|
||||
), onupdate=func.current_timestamp())
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
updated_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
# Add relationships
|
||||
academic_period = relationship(
|
||||
"AcademicPeriod", foreign_keys=[academic_period_id])
|
||||
event_media = relationship("EventMedia", foreign_keys=[event_media_id])
|
||||
|
||||
|
||||
class EventMedia(Base):
|
||||
__tablename__ = 'event_media'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
academic_period_id = Column(Integer, ForeignKey(
|
||||
# Optional für bessere Organisation
|
||||
'academic_periods.id'), nullable=True, index=True)
|
||||
media_type = Column(Enum(MediaType), nullable=False)
|
||||
url = Column(String(255), nullable=False)
|
||||
file_path = Column(String(255), nullable=True)
|
||||
message_content = Column(Text, nullable=True)
|
||||
uploaded_at = Column(TIMESTAMP, nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# Add relationship
|
||||
academic_period = relationship(
|
||||
"AcademicPeriod", foreign_keys=[academic_period_id])
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"academic_period_id": self.academic_period_id,
|
||||
"media_type": self.media_type.value if self.media_type else None,
|
||||
"url": self.url,
|
||||
"file_path": self.file_path,
|
||||
"message_content": self.message_content,
|
||||
}
|
||||
|
||||
|
||||
class SchoolHoliday(Base):
|
||||
__tablename__ = 'school_holidays'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(150), nullable=False)
|
||||
start_date = Column(Date, nullable=False, index=True)
|
||||
end_date = Column(Date, nullable=False, index=True)
|
||||
region = Column(String(100), nullable=True, index=True)
|
||||
source_file_name = Column(String(255), nullable=True)
|
||||
imported_at = Column(TIMESTAMP(timezone=True),
|
||||
server_default=func.current_timestamp())
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('name', 'start_date', 'end_date',
|
||||
'region', name='uq_school_holidays_unique'),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"start_date": self.start_date.isoformat() if self.start_date else None,
|
||||
"end_date": self.end_date.isoformat() if self.end_date else None,
|
||||
"region": self.region,
|
||||
"source_file_name": self.source_file_name,
|
||||
"imported_at": self.imported_at.isoformat() if self.imported_at else None,
|
||||
}
|
||||
|
||||
# --- Conversions: Track PPT/PPTX/ODP -> PDF processing state ---
|
||||
|
||||
|
||||
class ConversionStatus(enum.Enum):
|
||||
pending = "pending"
|
||||
processing = "processing"
|
||||
ready = "ready"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class Conversion(Base):
|
||||
__tablename__ = 'conversions'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
# Source media to be converted
|
||||
source_event_media_id = Column(
|
||||
Integer,
|
||||
ForeignKey('event_media.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
target_format = Column(String(10), nullable=False,
|
||||
index=True) # e.g. 'pdf'
|
||||
# relative to server/media
|
||||
target_path = Column(String(512), nullable=True)
|
||||
status = Column(Enum(ConversionStatus), nullable=False,
|
||||
default=ConversionStatus.pending)
|
||||
file_hash = Column(String(64), nullable=False) # sha256 of source file
|
||||
started_at = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
completed_at = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
error_message = Column(Text, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
# Fast lookup per media/format
|
||||
Index('ix_conv_source_target', 'source_event_media_id', 'target_format'),
|
||||
# Operational filtering
|
||||
Index('ix_conv_status_target', 'status', 'target_format'),
|
||||
# Idempotency: same source + target + file content should be unique
|
||||
UniqueConstraint('source_event_media_id', 'target_format',
|
||||
'file_hash', name='uq_conv_source_target_hash'),
|
||||
)
|
||||
29
nginx.conf
Normal file
29
nginx.conf
Normal file
@@ -0,0 +1,29 @@
|
||||
events {}
|
||||
http {
|
||||
upstream dashboard {
|
||||
server infoscreen-dashboard:80;
|
||||
}
|
||||
upstream infoscreen_api {
|
||||
server infoscreen-api:8000;
|
||||
}
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Leitet /api/ und /screenshots/ an den API-Server weiter
|
||||
location /api/ {
|
||||
proxy_pass http://infoscreen_api/api/;
|
||||
}
|
||||
location /screenshots/ {
|
||||
proxy_pass http://infoscreen_api/screenshots/;
|
||||
}
|
||||
# Alles andere geht ans Frontend
|
||||
location / {
|
||||
proxy_pass http://dashboard;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
}
|
||||
47
nginx.dev.conf
Normal file
47
nginx.dev.conf
Normal file
@@ -0,0 +1,47 @@
|
||||
events {}
|
||||
http {
|
||||
upstream dashboard {
|
||||
# Vite dev server inside the dashboard container
|
||||
server infoscreen-dashboard:5173;
|
||||
}
|
||||
upstream infoscreen_api {
|
||||
server infoscreen-api:8000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Proxy /api and /screenshots to the Flask API
|
||||
location /api/ {
|
||||
proxy_pass http://infoscreen_api/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /screenshots/ {
|
||||
proxy_pass http://infoscreen_api/screenshots/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Everything else to the Vite dev server
|
||||
location / {
|
||||
proxy_pass http://dashboard;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# WebSocket upgrade for Vite HMR
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
}
|
||||
477
pptx_conversion_guide.md
Normal file
477
pptx_conversion_guide.md
Normal file
@@ -0,0 +1,477 @@
|
||||
# Recommended Implementation: PPTX-to-PDF Conversion System
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
**Asynchronous server-side conversion with database tracking**
|
||||
|
||||
```
|
||||
User Upload → API saves PPTX + DB entry → Job in Queue
|
||||
↓
|
||||
Client requests → API checks DB status → PDF ready? → Download PDF
|
||||
→ Pending? → "Please wait"
|
||||
→ Failed? → Retry/Error
|
||||
```
|
||||
|
||||
## 1. Database Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE media_files (
|
||||
id UUID PRIMARY KEY,
|
||||
filename VARCHAR(255),
|
||||
original_path VARCHAR(512),
|
||||
file_type VARCHAR(10),
|
||||
mime_type VARCHAR(100),
|
||||
uploaded_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE conversions (
|
||||
id UUID PRIMARY KEY,
|
||||
source_file_id UUID REFERENCES media_files(id) ON DELETE CASCADE,
|
||||
target_format VARCHAR(10), -- 'pdf'
|
||||
target_path VARCHAR(512), -- Path to generated PDF
|
||||
status VARCHAR(20), -- 'pending', 'processing', 'ready', 'failed'
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
error_message TEXT,
|
||||
file_hash VARCHAR(64) -- Hash of PPTX for cache invalidation
|
||||
);
|
||||
|
||||
CREATE INDEX idx_conversions_source ON conversions(source_file_id, target_format);
|
||||
```
|
||||
|
||||
## 2. Components
|
||||
|
||||
### **API Server (existing)**
|
||||
- Accepts uploads
|
||||
- Creates DB entries
|
||||
- Enqueues jobs
|
||||
- Delivers status and files
|
||||
|
||||
### **Background Worker (new)**
|
||||
- Runs as separate process in **same container** as API
|
||||
- Processes conversion jobs from queue
|
||||
- Can run multiple worker instances in parallel
|
||||
- Technology: Python RQ, Celery, or similar
|
||||
|
||||
### **Message Queue**
|
||||
- Redis (recommended for start - simple, fast)
|
||||
- Alternative: RabbitMQ for more features
|
||||
|
||||
### **Redis Container (new)**
|
||||
- Separate container for Redis
|
||||
- Handles job queue
|
||||
- Minimal resource footprint
|
||||
|
||||
## 3. Detailed Workflow
|
||||
|
||||
### **Upload Process:**
|
||||
|
||||
```python
|
||||
@app.post("/upload")
|
||||
async def upload_file(file):
|
||||
# 1. Save PPTX
|
||||
file_path = save_to_disk(file)
|
||||
|
||||
# 2. DB entry for original file
|
||||
file_record = db.create_media_file({
|
||||
'filename': file.filename,
|
||||
'original_path': file_path,
|
||||
'file_type': 'pptx'
|
||||
})
|
||||
|
||||
# 3. Create conversion record
|
||||
conversion = db.create_conversion({
|
||||
'source_file_id': file_record.id,
|
||||
'target_format': 'pdf',
|
||||
'status': 'pending',
|
||||
'file_hash': calculate_hash(file_path)
|
||||
})
|
||||
|
||||
# 4. Enqueue job (asynchronous!)
|
||||
queue.enqueue(convert_to_pdf, conversion.id)
|
||||
|
||||
# 5. Return immediately to user
|
||||
return {
|
||||
'file_id': file_record.id,
|
||||
'status': 'uploaded',
|
||||
'conversion_status': 'pending'
|
||||
}
|
||||
```
|
||||
|
||||
### **Worker Process:**
|
||||
|
||||
```python
|
||||
def convert_to_pdf(conversion_id):
|
||||
conversion = db.get_conversion(conversion_id)
|
||||
source_file = db.get_media_file(conversion.source_file_id)
|
||||
|
||||
# Status update: processing
|
||||
db.update_conversion(conversion_id, {
|
||||
'status': 'processing',
|
||||
'started_at': now()
|
||||
})
|
||||
|
||||
try:
|
||||
# LibreOffice Conversion
|
||||
pdf_path = f"/data/converted/{conversion.id}.pdf"
|
||||
subprocess.run([
|
||||
'libreoffice',
|
||||
'--headless',
|
||||
'--convert-to', 'pdf',
|
||||
'--outdir', '/data/converted/',
|
||||
source_file.original_path
|
||||
], check=True)
|
||||
|
||||
# Success
|
||||
db.update_conversion(conversion_id, {
|
||||
'status': 'ready',
|
||||
'target_path': pdf_path,
|
||||
'completed_at': now()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
# Error
|
||||
db.update_conversion(conversion_id, {
|
||||
'status': 'failed',
|
||||
'error_message': str(e),
|
||||
'completed_at': now()
|
||||
})
|
||||
```
|
||||
|
||||
### **Client Download:**
|
||||
|
||||
```python
|
||||
@app.get("/files/{file_id}/display")
|
||||
async def get_display_file(file_id):
|
||||
file = db.get_media_file(file_id)
|
||||
|
||||
# Only for PPTX: check PDF conversion
|
||||
if file.file_type == 'pptx':
|
||||
conversion = db.get_latest_conversion(file.id, target_format='pdf')
|
||||
|
||||
if not conversion:
|
||||
# Shouldn't happen, but just to be safe
|
||||
trigger_new_conversion(file.id)
|
||||
return {'status': 'pending', 'message': 'Conversion is being created'}
|
||||
|
||||
if conversion.status == 'ready':
|
||||
return FileResponse(conversion.target_path)
|
||||
|
||||
elif conversion.status == 'failed':
|
||||
# Optional: Auto-retry
|
||||
trigger_new_conversion(file.id)
|
||||
return {'status': 'failed', 'error': conversion.error_message}
|
||||
|
||||
else: # pending or processing
|
||||
return {'status': conversion.status, 'message': 'Please wait...'}
|
||||
|
||||
# Serve other file types directly
|
||||
return FileResponse(file.original_path)
|
||||
```
|
||||
|
||||
## 4. Docker Setup
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Your API Server
|
||||
api:
|
||||
build: ./api
|
||||
command: uvicorn main:app --host 0.0.0.0 --port 8000
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./data/uploads:/data/uploads
|
||||
- ./data/converted:/data/converted
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- DATABASE_URL=postgresql://postgres:password@postgres:5432/infoscreen
|
||||
depends_on:
|
||||
- redis
|
||||
- postgres
|
||||
restart: unless-stopped
|
||||
|
||||
# Worker (same codebase as API, different command)
|
||||
worker:
|
||||
build: ./api # Same build as API!
|
||||
command: python worker.py # or: rq worker
|
||||
volumes:
|
||||
- ./data/uploads:/data/uploads
|
||||
- ./data/converted:/data/converted
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- DATABASE_URL=postgresql://postgres:password@postgres:5432/infoscreen
|
||||
depends_on:
|
||||
- redis
|
||||
- postgres
|
||||
restart: unless-stopped
|
||||
# Optional: Multiple workers
|
||||
deploy:
|
||||
replicas: 2
|
||||
|
||||
# Redis - separate container
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
# Optional: persistent configuration
|
||||
command: redis-server --appendonly yes
|
||||
restart: unless-stopped
|
||||
|
||||
# Your existing Postgres
|
||||
postgres:
|
||||
image: postgres:15
|
||||
environment:
|
||||
- POSTGRES_DB=infoscreen
|
||||
- POSTGRES_PASSWORD=password
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
# Optional: Redis Commander (UI for debugging)
|
||||
redis-commander:
|
||||
image: rediscommander/redis-commander
|
||||
environment:
|
||||
- REDIS_HOSTS=local:redis:6379
|
||||
ports:
|
||||
- "8081:8081"
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
postgres-data:
|
||||
```
|
||||
|
||||
## 5. Container Communication
|
||||
|
||||
Containers communicate via **Docker's internal network**:
|
||||
|
||||
```python
|
||||
# In your API/Worker code:
|
||||
import redis
|
||||
|
||||
# Connection to Redis
|
||||
redis_client = redis.from_url('redis://redis:6379')
|
||||
# ^^^^^^
|
||||
# Container name = hostname in Docker network
|
||||
```
|
||||
|
||||
Docker automatically creates DNS entries, so `redis` resolves to the Redis container.
|
||||
|
||||
## 6. Client Behavior (Pi5)
|
||||
|
||||
```python
|
||||
# On the Pi5 client
|
||||
def display_file(file_id):
|
||||
response = api.get(f"/files/{file_id}/display")
|
||||
|
||||
if response.content_type == 'application/pdf':
|
||||
# PDF is ready
|
||||
download_and_display(response)
|
||||
subprocess.run(['impressive', downloaded_pdf])
|
||||
|
||||
elif response.json()['status'] in ['pending', 'processing']:
|
||||
# Wait and retry
|
||||
show_loading_screen("Presentation is being prepared...")
|
||||
time.sleep(5)
|
||||
display_file(file_id) # Retry
|
||||
|
||||
else:
|
||||
# Error
|
||||
show_error_screen("Error loading presentation")
|
||||
```
|
||||
|
||||
## 7. Additional Features
|
||||
|
||||
### **Cache Invalidation on PPTX Update:**
|
||||
|
||||
```python
|
||||
@app.put("/files/{file_id}")
|
||||
async def update_file(file_id, new_file):
|
||||
# Delete old conversions
|
||||
db.mark_conversions_as_obsolete(file_id)
|
||||
|
||||
# Update file
|
||||
update_media_file(file_id, new_file)
|
||||
|
||||
# Trigger new conversion
|
||||
trigger_conversion(file_id, 'pdf')
|
||||
```
|
||||
|
||||
### **Status API for Monitoring:**
|
||||
|
||||
```python
|
||||
@app.get("/admin/conversions/status")
|
||||
async def get_conversion_stats():
|
||||
return {
|
||||
'pending': db.count(status='pending'),
|
||||
'processing': db.count(status='processing'),
|
||||
'failed': db.count(status='failed'),
|
||||
'avg_duration_seconds': db.avg_duration()
|
||||
}
|
||||
```
|
||||
|
||||
### **Cleanup Job (Cronjob):**
|
||||
|
||||
```python
|
||||
def cleanup_old_conversions():
|
||||
# Remove PDFs from deleted files
|
||||
db.delete_orphaned_conversions()
|
||||
|
||||
# Clean up old failed conversions
|
||||
db.delete_old_failed_conversions(older_than_days=7)
|
||||
```
|
||||
|
||||
## 8. Redis Container Details
|
||||
|
||||
### **Why Separate Container?**
|
||||
|
||||
✅ **Separation of Concerns**: Each service has its own responsibility
|
||||
✅ **Independent Lifecycle Management**: Redis can be restarted/updated independently
|
||||
✅ **Better Scaling**: Redis can be moved to different hardware
|
||||
✅ **Easier Backup**: Redis data can be backed up separately
|
||||
✅ **Standard Docker Pattern**: Microservices architecture
|
||||
|
||||
### **Resource Usage:**
|
||||
- RAM: ~10-50 MB for your use case
|
||||
- CPU: Minimal
|
||||
- Disk: Only for persistence (optional)
|
||||
|
||||
For 10 clients with occasional PPTX uploads, this is absolutely no problem.
|
||||
|
||||
## 9. Advantages of This Solution
|
||||
|
||||
✅ **Scalable**: Workers can be scaled horizontally
|
||||
✅ **Performant**: Clients don't wait for conversion
|
||||
✅ **Robust**: Status tracking and error handling
|
||||
✅ **Maintainable**: Clear separation of responsibilities
|
||||
✅ **Transparent**: Status queryable at any time
|
||||
✅ **Efficient**: One-time conversion per file
|
||||
✅ **Future-proof**: Easily extensible for other formats
|
||||
✅ **Professional**: Industry-standard architecture
|
||||
|
||||
## 10. Migration Path
|
||||
|
||||
### **Phase 1 (MVP):**
|
||||
- 1 worker process in API container
|
||||
- Redis for queue (separate container)
|
||||
- Basic DB schema
|
||||
- Simple retry logic
|
||||
|
||||
### **Phase 2 (as needed):**
|
||||
- Multiple worker instances
|
||||
- Dedicated conversion service container
|
||||
- Monitoring & alerting
|
||||
- Prioritization logic
|
||||
- Advanced caching strategies
|
||||
|
||||
**Start simple, scale when needed!**
|
||||
|
||||
## 11. Key Decisions Summary
|
||||
|
||||
| Aspect | Decision | Reason |
|
||||
|--------|----------|--------|
|
||||
| **Conversion Location** | Server-side | One conversion per file, consistent results |
|
||||
| **Conversion Timing** | Asynchronous (on upload) | No client waiting time, predictable performance |
|
||||
| **Data Storage** | Database-tracked | Status visibility, robust error handling |
|
||||
| **Queue System** | Redis (separate container) | Standard pattern, scalable, maintainable |
|
||||
| **Worker Architecture** | Background process in API container | Simple start, easy to separate later |
|
||||
|
||||
## 12. File Flow Diagram
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ User Upload │
|
||||
│ (PPTX) │
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ API Server │
|
||||
│ 1. Save PPTX │
|
||||
│ 2. Create DB rec │
|
||||
│ 3. Enqueue job │
|
||||
└──────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Redis Queue │◄─────┐
|
||||
└──────┬───────────┘ │
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────────────┐ │
|
||||
│ Worker Process │ │
|
||||
│ 1. Get job │ │
|
||||
│ 2. Convert PPTX │ │
|
||||
│ 3. Update DB │ │
|
||||
└──────┬───────────┘ │
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────────────┐ │
|
||||
│ PDF Storage │ │
|
||||
└──────┬───────────┘ │
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────────────┐ │
|
||||
│ Client Requests │ │
|
||||
│ 1. Check DB │ │
|
||||
│ 2. Download PDF │ │
|
||||
│ 3. Display │──────┘
|
||||
└──────────────────┘
|
||||
(via impressive)
|
||||
```
|
||||
|
||||
## 13. Implementation Checklist
|
||||
|
||||
### Database Setup
|
||||
- [ ] Create `media_files` table
|
||||
- [ ] Create `conversions` table
|
||||
- [ ] Add indexes for performance
|
||||
- [ ] Set up foreign key constraints
|
||||
|
||||
### API Changes
|
||||
- [ ] Modify upload endpoint to create DB records
|
||||
- [ ] Add conversion job enqueueing
|
||||
- [ ] Implement file download endpoint with status checking
|
||||
- [ ] Add status API for monitoring
|
||||
- [ ] Implement cache invalidation on file update
|
||||
|
||||
### Worker Setup
|
||||
- [ ] Create worker script/module
|
||||
- [ ] Implement LibreOffice conversion logic
|
||||
- [ ] Add error handling and retry logic
|
||||
- [ ] Set up logging and monitoring
|
||||
|
||||
### Docker Configuration
|
||||
- [ ] Add Redis container to docker-compose.yml
|
||||
- [ ] Configure worker container
|
||||
- [ ] Set up volume mounts for file storage
|
||||
- [ ] Configure environment variables
|
||||
- [ ] Set up container dependencies
|
||||
|
||||
### Client Updates
|
||||
- [ ] Modify client to check conversion status
|
||||
- [ ] Implement retry logic for pending conversions
|
||||
- [ ] Add loading/waiting screens
|
||||
- [ ] Implement error handling
|
||||
|
||||
### Testing
|
||||
- [ ] Test upload → conversion → download flow
|
||||
- [ ] Test multiple concurrent conversions
|
||||
- [ ] Test error handling (corrupted PPTX, etc.)
|
||||
- [ ] Test cache invalidation on file update
|
||||
- [ ] Load test with multiple clients
|
||||
|
||||
### Monitoring & Operations
|
||||
- [ ] Set up logging for conversions
|
||||
- [ ] Implement cleanup job for old files
|
||||
- [ ] Add metrics for conversion times
|
||||
- [ ] Set up alerts for failed conversions
|
||||
- [ ] Document backup procedures
|
||||
|
||||
---
|
||||
|
||||
**This architecture provides a solid foundation that's simple to start with but scales professionally as your needs grow!**
|
||||
815
pptx_conversion_guide_gotenberg.md
Normal file
815
pptx_conversion_guide_gotenberg.md
Normal file
@@ -0,0 +1,815 @@
|
||||
# Recommended Implementation: PPTX-to-PDF Conversion System with Gotenberg
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
**Asynchronous server-side conversion using Gotenberg with shared storage**
|
||||
|
||||
```
|
||||
User Upload → API saves PPTX → Job in Queue → Worker calls Gotenberg API
|
||||
↓
|
||||
Gotenberg converts via shared volume
|
||||
↓
|
||||
Client requests → API checks DB status → PDF ready? → Download PDF from shared storage
|
||||
→ Pending? → "Please wait"
|
||||
→ Failed? → Retry/Error
|
||||
```
|
||||
|
||||
## 1. Database Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE media_files (
|
||||
id UUID PRIMARY KEY,
|
||||
filename VARCHAR(255),
|
||||
original_path VARCHAR(512),
|
||||
file_type VARCHAR(10),
|
||||
mime_type VARCHAR(100),
|
||||
uploaded_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE conversions (
|
||||
id UUID PRIMARY KEY,
|
||||
source_file_id UUID REFERENCES media_files(id) ON DELETE CASCADE,
|
||||
target_format VARCHAR(10), -- 'pdf'
|
||||
target_path VARCHAR(512), -- Path to generated PDF
|
||||
status VARCHAR(20), -- 'pending', 'processing', 'ready', 'failed'
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
error_message TEXT,
|
||||
file_hash VARCHAR(64) -- Hash of PPTX for cache invalidation
|
||||
);
|
||||
|
||||
CREATE INDEX idx_conversions_source ON conversions(source_file_id, target_format);
|
||||
```
|
||||
|
||||
## 2. Components
|
||||
|
||||
### **API Server (existing)**
|
||||
- Accepts uploads
|
||||
- Creates DB entries
|
||||
- Enqueues jobs
|
||||
- Delivers status and files
|
||||
|
||||
### **Background Worker (new)**
|
||||
- Runs as separate process in **same container** as API
|
||||
- Processes conversion jobs from queue
|
||||
- Calls Gotenberg API for conversion
|
||||
- Updates database with results
|
||||
- Technology: Python RQ, Celery, or similar
|
||||
|
||||
### **Gotenberg Container (new)**
|
||||
- Dedicated conversion service
|
||||
- HTTP API for document conversion
|
||||
- Handles LibreOffice conversions internally
|
||||
- Accesses files via shared volume
|
||||
|
||||
### **Message Queue**
|
||||
- Redis (recommended for start - simple, fast)
|
||||
- Alternative: RabbitMQ for more features
|
||||
|
||||
### **Redis Container (separate)**
|
||||
- Handles job queue
|
||||
- Minimal resource footprint
|
||||
|
||||
### **Shared Storage**
|
||||
- Docker volume mounted to all containers that need file access
|
||||
- API, Worker, and Gotenberg all access same files
|
||||
- Simplifies file exchange between services
|
||||
|
||||
## 3. Detailed Workflow
|
||||
|
||||
### **Upload Process:**
|
||||
|
||||
```python
|
||||
@app.post("/upload")
|
||||
async def upload_file(file):
|
||||
# 1. Save PPTX to shared volume
|
||||
file_path = save_to_disk(file) # e.g., /shared/uploads/abc123.pptx
|
||||
|
||||
# 2. DB entry for original file
|
||||
file_record = db.create_media_file({
|
||||
'filename': file.filename,
|
||||
'original_path': file_path,
|
||||
'file_type': 'pptx'
|
||||
})
|
||||
|
||||
# 3. Create conversion record
|
||||
conversion = db.create_conversion({
|
||||
'source_file_id': file_record.id,
|
||||
'target_format': 'pdf',
|
||||
'status': 'pending',
|
||||
'file_hash': calculate_hash(file_path)
|
||||
})
|
||||
|
||||
# 4. Enqueue job (asynchronous!)
|
||||
queue.enqueue(convert_to_pdf_via_gotenberg, conversion.id)
|
||||
|
||||
# 5. Return immediately to user
|
||||
return {
|
||||
'file_id': file_record.id,
|
||||
'status': 'uploaded',
|
||||
'conversion_status': 'pending'
|
||||
}
|
||||
```
|
||||
|
||||
### **Worker Process (calls Gotenberg):**
|
||||
|
||||
```python
|
||||
import requests
|
||||
import os
|
||||
|
||||
GOTENBERG_URL = os.getenv('GOTENBERG_URL', 'http://gotenberg:3000')
|
||||
|
||||
def convert_to_pdf_via_gotenberg(conversion_id):
|
||||
conversion = db.get_conversion(conversion_id)
|
||||
source_file = db.get_media_file(conversion.source_file_id)
|
||||
|
||||
# Status update: processing
|
||||
db.update_conversion(conversion_id, {
|
||||
'status': 'processing',
|
||||
'started_at': now()
|
||||
})
|
||||
|
||||
try:
|
||||
# Prepare output path
|
||||
pdf_filename = f"{conversion.id}.pdf"
|
||||
pdf_path = f"/shared/converted/{pdf_filename}"
|
||||
|
||||
# Call Gotenberg API
|
||||
# Gotenberg accesses the file via shared volume
|
||||
with open(source_file.original_path, 'rb') as f:
|
||||
files = {
|
||||
'files': (os.path.basename(source_file.original_path), f)
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f'{GOTENBERG_URL}/forms/libreoffice/convert',
|
||||
files=files,
|
||||
timeout=300 # 5 minutes timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Save PDF to shared volume
|
||||
with open(pdf_path, 'wb') as pdf_file:
|
||||
pdf_file.write(response.content)
|
||||
|
||||
# Success
|
||||
db.update_conversion(conversion_id, {
|
||||
'status': 'ready',
|
||||
'target_path': pdf_path,
|
||||
'completed_at': now()
|
||||
})
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
db.update_conversion(conversion_id, {
|
||||
'status': 'failed',
|
||||
'error_message': 'Conversion timeout after 5 minutes',
|
||||
'completed_at': now()
|
||||
})
|
||||
except requests.exceptions.RequestException as e:
|
||||
db.update_conversion(conversion_id, {
|
||||
'status': 'failed',
|
||||
'error_message': f'Gotenberg API error: {str(e)}',
|
||||
'completed_at': now()
|
||||
})
|
||||
except Exception as e:
|
||||
db.update_conversion(conversion_id, {
|
||||
'status': 'failed',
|
||||
'error_message': str(e),
|
||||
'completed_at': now()
|
||||
})
|
||||
```
|
||||
|
||||
### **Alternative: Direct File Access via Shared Volume**
|
||||
|
||||
If you prefer Gotenberg to read from shared storage directly (more efficient for large files):
|
||||
|
||||
```python
|
||||
def convert_to_pdf_via_gotenberg_shared(conversion_id):
|
||||
conversion = db.get_conversion(conversion_id)
|
||||
source_file = db.get_media_file(conversion.source_file_id)
|
||||
|
||||
db.update_conversion(conversion_id, {
|
||||
'status': 'processing',
|
||||
'started_at': now()
|
||||
})
|
||||
|
||||
try:
|
||||
pdf_filename = f"{conversion.id}.pdf"
|
||||
pdf_path = f"/shared/converted/{pdf_filename}"
|
||||
|
||||
# Gotenberg reads directly from shared volume
|
||||
# We just tell it where to find the file
|
||||
with open(source_file.original_path, 'rb') as f:
|
||||
files = {'files': f}
|
||||
|
||||
response = requests.post(
|
||||
f'{GOTENBERG_URL}/forms/libreoffice/convert',
|
||||
files=files,
|
||||
timeout=300
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Write result to shared volume
|
||||
with open(pdf_path, 'wb') as pdf_file:
|
||||
pdf_file.write(response.content)
|
||||
|
||||
db.update_conversion(conversion_id, {
|
||||
'status': 'ready',
|
||||
'target_path': pdf_path,
|
||||
'completed_at': now()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.update_conversion(conversion_id, {
|
||||
'status': 'failed',
|
||||
'error_message': str(e),
|
||||
'completed_at': now()
|
||||
})
|
||||
```
|
||||
|
||||
### **Client Download:**
|
||||
|
||||
```python
|
||||
@app.get("/files/{file_id}/display")
|
||||
async def get_display_file(file_id):
|
||||
file = db.get_media_file(file_id)
|
||||
|
||||
# Only for PPTX: check PDF conversion
|
||||
if file.file_type == 'pptx':
|
||||
conversion = db.get_latest_conversion(file.id, target_format='pdf')
|
||||
|
||||
if not conversion:
|
||||
# Shouldn't happen, but just to be safe
|
||||
trigger_new_conversion(file.id)
|
||||
return {'status': 'pending', 'message': 'Conversion is being created'}
|
||||
|
||||
if conversion.status == 'ready':
|
||||
# Serve PDF from shared storage
|
||||
return FileResponse(conversion.target_path)
|
||||
|
||||
elif conversion.status == 'failed':
|
||||
# Optional: Auto-retry
|
||||
trigger_new_conversion(file.id)
|
||||
return {'status': 'failed', 'error': conversion.error_message}
|
||||
|
||||
else: # pending or processing
|
||||
return {'status': conversion.status, 'message': 'Please wait...'}
|
||||
|
||||
# Serve other file types directly
|
||||
return FileResponse(file.original_path)
|
||||
```
|
||||
|
||||
## 4. Docker Setup
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Your API Server
|
||||
api:
|
||||
build: ./api
|
||||
command: uvicorn main:app --host 0.0.0.0 --port 8000
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- shared-storage:/shared # Shared volume
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- DATABASE_URL=postgresql://postgres:password@postgres:5432/infoscreen
|
||||
- GOTENBERG_URL=http://gotenberg:3000
|
||||
depends_on:
|
||||
- redis
|
||||
- postgres
|
||||
- gotenberg
|
||||
restart: unless-stopped
|
||||
|
||||
# Worker (same codebase as API, different command)
|
||||
worker:
|
||||
build: ./api # Same build as API!
|
||||
command: python worker.py # or: rq worker
|
||||
volumes:
|
||||
- shared-storage:/shared # Shared volume
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- DATABASE_URL=postgresql://postgres:password@postgres:5432/infoscreen
|
||||
- GOTENBERG_URL=http://gotenberg:3000
|
||||
depends_on:
|
||||
- redis
|
||||
- postgres
|
||||
- gotenberg
|
||||
restart: unless-stopped
|
||||
# Optional: Multiple workers
|
||||
deploy:
|
||||
replicas: 2
|
||||
|
||||
# Gotenberg - Document Conversion Service
|
||||
gotenberg:
|
||||
image: gotenberg/gotenberg:8
|
||||
# Gotenberg doesn't need the shared volume if files are sent via HTTP
|
||||
# But mount it if you want direct file access
|
||||
volumes:
|
||||
- shared-storage:/shared # Optional: for direct file access
|
||||
environment:
|
||||
# Gotenberg configuration
|
||||
- GOTENBERG_API_TIMEOUT=300s
|
||||
- GOTENBERG_LOG_LEVEL=info
|
||||
restart: unless-stopped
|
||||
# Resource limits (optional but recommended)
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 2G
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
|
||||
# Redis - separate container
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
command: redis-server --appendonly yes
|
||||
restart: unless-stopped
|
||||
|
||||
# Your existing Postgres
|
||||
postgres:
|
||||
image: postgres:15
|
||||
environment:
|
||||
- POSTGRES_DB=infoscreen
|
||||
- POSTGRES_PASSWORD=password
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
# Optional: Redis Commander (UI for debugging)
|
||||
redis-commander:
|
||||
image: rediscommander/redis-commander
|
||||
environment:
|
||||
- REDIS_HOSTS=local:redis:6379
|
||||
ports:
|
||||
- "8081:8081"
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
volumes:
|
||||
shared-storage: # New: Shared storage for all file operations
|
||||
redis-data:
|
||||
postgres-data:
|
||||
```
|
||||
|
||||
## 5. Storage Structure
|
||||
|
||||
```
|
||||
/shared/
|
||||
├── uploads/ # Original uploaded files (PPTX, etc.)
|
||||
│ ├── abc123.pptx
|
||||
│ ├── def456.pptx
|
||||
│ └── ...
|
||||
└── converted/ # Converted PDF files
|
||||
├── uuid-1.pdf
|
||||
├── uuid-2.pdf
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 6. Gotenberg Integration Details
|
||||
|
||||
### **Gotenberg API Endpoints:**
|
||||
|
||||
Gotenberg provides various conversion endpoints:
|
||||
|
||||
```python
|
||||
# LibreOffice conversion (for PPTX, DOCX, ODT, etc.)
|
||||
POST http://gotenberg:3000/forms/libreoffice/convert
|
||||
|
||||
# HTML to PDF
|
||||
POST http://gotenberg:3000/forms/chromium/convert/html
|
||||
|
||||
# Markdown to PDF
|
||||
POST http://gotenberg:3000/forms/chromium/convert/markdown
|
||||
|
||||
# Merge PDFs
|
||||
POST http://gotenberg:3000/forms/pdfengines/merge
|
||||
```
|
||||
|
||||
### **Example Conversion Request:**
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
def convert_with_gotenberg(input_file_path, output_file_path):
|
||||
"""
|
||||
Convert document using Gotenberg
|
||||
"""
|
||||
with open(input_file_path, 'rb') as f:
|
||||
files = {
|
||||
'files': (os.path.basename(input_file_path), f,
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation')
|
||||
}
|
||||
|
||||
# Optional: Add conversion parameters
|
||||
data = {
|
||||
'landscape': 'false', # Portrait mode
|
||||
'nativePageRanges': '1-', # All pages
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
'http://gotenberg:3000/forms/libreoffice/convert',
|
||||
files=files,
|
||||
data=data,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
with open(output_file_path, 'wb') as out:
|
||||
out.write(response.content)
|
||||
return True
|
||||
else:
|
||||
raise Exception(f"Gotenberg error: {response.status_code} - {response.text}")
|
||||
```
|
||||
|
||||
### **Advanced Options:**
|
||||
|
||||
```python
|
||||
# With custom PDF properties
|
||||
data = {
|
||||
'landscape': 'false',
|
||||
'nativePageRanges': '1-10', # Only first 10 pages
|
||||
'pdfFormat': 'PDF/A-1a', # PDF/A format
|
||||
'exportFormFields': 'false',
|
||||
}
|
||||
|
||||
# With password protection
|
||||
data = {
|
||||
'userPassword': 'secret123',
|
||||
'ownerPassword': 'admin456',
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Client Behavior (Pi5)
|
||||
|
||||
```python
|
||||
# On the Pi5 client
|
||||
def display_file(file_id):
|
||||
response = api.get(f"/files/{file_id}/display")
|
||||
|
||||
if response.content_type == 'application/pdf':
|
||||
# PDF is ready
|
||||
download_and_display(response)
|
||||
subprocess.run(['impressive', downloaded_pdf])
|
||||
|
||||
elif response.json()['status'] in ['pending', 'processing']:
|
||||
# Wait and retry
|
||||
show_loading_screen("Presentation is being prepared...")
|
||||
time.sleep(5)
|
||||
display_file(file_id) # Retry
|
||||
|
||||
else:
|
||||
# Error
|
||||
show_error_screen("Error loading presentation")
|
||||
```
|
||||
|
||||
## 8. Additional Features
|
||||
|
||||
### **Cache Invalidation on PPTX Update:**
|
||||
|
||||
```python
|
||||
@app.put("/files/{file_id}")
|
||||
async def update_file(file_id, new_file):
|
||||
# Delete old conversions and PDFs
|
||||
conversions = db.get_conversions_for_file(file_id)
|
||||
for conv in conversions:
|
||||
if conv.target_path and os.path.exists(conv.target_path):
|
||||
os.remove(conv.target_path)
|
||||
|
||||
db.mark_conversions_as_obsolete(file_id)
|
||||
|
||||
# Update file
|
||||
update_media_file(file_id, new_file)
|
||||
|
||||
# Trigger new conversion
|
||||
trigger_conversion(file_id, 'pdf')
|
||||
```
|
||||
|
||||
### **Status API for Monitoring:**
|
||||
|
||||
```python
|
||||
@app.get("/admin/conversions/status")
|
||||
async def get_conversion_stats():
|
||||
return {
|
||||
'pending': db.count(status='pending'),
|
||||
'processing': db.count(status='processing'),
|
||||
'failed': db.count(status='failed'),
|
||||
'avg_duration_seconds': db.avg_duration(),
|
||||
'gotenberg_health': check_gotenberg_health()
|
||||
}
|
||||
|
||||
def check_gotenberg_health():
|
||||
try:
|
||||
response = requests.get(
|
||||
f'{GOTENBERG_URL}/health',
|
||||
timeout=5
|
||||
)
|
||||
return response.status_code == 200
|
||||
except:
|
||||
return False
|
||||
```
|
||||
|
||||
### **Cleanup Job (Cronjob):**
|
||||
|
||||
```python
|
||||
def cleanup_old_conversions():
|
||||
# Remove PDFs from deleted files
|
||||
orphaned = db.get_orphaned_conversions()
|
||||
for conv in orphaned:
|
||||
if conv.target_path and os.path.exists(conv.target_path):
|
||||
os.remove(conv.target_path)
|
||||
db.delete_conversion(conv.id)
|
||||
|
||||
# Clean up old failed conversions
|
||||
old_failed = db.get_old_failed_conversions(older_than_days=7)
|
||||
for conv in old_failed:
|
||||
db.delete_conversion(conv.id)
|
||||
```
|
||||
|
||||
## 9. Advantages of Using Gotenberg
|
||||
|
||||
✅ **Specialized Service**: Optimized specifically for document conversion
|
||||
✅ **No LibreOffice Management**: Gotenberg handles LibreOffice lifecycle internally
|
||||
✅ **Better Resource Management**: Isolated conversion process
|
||||
✅ **HTTP API**: Clean, standard interface
|
||||
✅ **Production Ready**: Battle-tested, actively maintained
|
||||
✅ **Multiple Formats**: Supports PPTX, DOCX, ODT, HTML, Markdown, etc.
|
||||
✅ **PDF Features**: Merge, encrypt, watermark PDFs
|
||||
✅ **Health Checks**: Built-in health endpoint
|
||||
✅ **Horizontal Scaling**: Can run multiple Gotenberg instances
|
||||
✅ **Memory Safe**: Automatic cleanup and restart on issues
|
||||
|
||||
## 10. Migration Path
|
||||
|
||||
### **Phase 1 (MVP):**
|
||||
- 1 worker process in API container
|
||||
- Redis for queue (separate container)
|
||||
- Gotenberg for conversion (separate container)
|
||||
- Basic DB schema
|
||||
- Shared volume for file exchange
|
||||
- Simple retry logic
|
||||
|
||||
### **Phase 2 (as needed):**
|
||||
- Multiple worker instances
|
||||
- Multiple Gotenberg instances (load balancing)
|
||||
- Monitoring & alerting
|
||||
- Prioritization logic
|
||||
- Advanced caching strategies
|
||||
- PDF optimization/compression
|
||||
|
||||
**Start simple, scale when needed!**
|
||||
|
||||
## 11. Key Decisions Summary
|
||||
|
||||
| Aspect | Decision | Reason |
|
||||
|--------|----------|--------|
|
||||
| **Conversion Location** | Server-side (Gotenberg) | One conversion per file, consistent results |
|
||||
| **Conversion Service** | Dedicated Gotenberg container | Specialized, production-ready, better isolation |
|
||||
| **Conversion Timing** | Asynchronous (on upload) | No client waiting time, predictable performance |
|
||||
| **Data Storage** | Database-tracked | Status visibility, robust error handling |
|
||||
| **File Exchange** | Shared Docker volume | Simple, efficient, no network overhead |
|
||||
| **Queue System** | Redis (separate container) | Standard pattern, scalable, maintainable |
|
||||
| **Worker Architecture** | Background process in API container | Simple start, easy to separate later |
|
||||
|
||||
## 12. File Flow Diagram
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ User Upload │
|
||||
│ (PPTX) │
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ API Server │
|
||||
│ 1. Save to /shared │
|
||||
│ 2. Create DB record │
|
||||
│ 3. Enqueue job │
|
||||
└──────┬───────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Redis Queue │
|
||||
└──────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ Worker Process │
|
||||
│ 1. Get job │
|
||||
│ 2. Call Gotenberg │
|
||||
│ 3. Update DB │
|
||||
└──────┬───────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ Gotenberg │
|
||||
│ 1. Read from /shared │
|
||||
│ 2. Convert PPTX │
|
||||
│ 3. Return PDF │
|
||||
└──────┬───────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ Worker saves PDF │
|
||||
│ to /shared/converted│
|
||||
└──────┬───────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ Client Requests │
|
||||
│ 1. Check DB │
|
||||
│ 2. Download PDF │
|
||||
│ 3. Display │
|
||||
└──────────────────────┘
|
||||
(via impressive)
|
||||
```
|
||||
|
||||
## 13. Implementation Checklist
|
||||
|
||||
### Database Setup
|
||||
- [ ] Create `media_files` table
|
||||
- [ ] Create `conversions` table
|
||||
- [ ] Add indexes for performance
|
||||
- [ ] Set up foreign key constraints
|
||||
|
||||
### Storage Setup
|
||||
- [ ] Create shared Docker volume
|
||||
- [ ] Set up directory structure (/shared/uploads, /shared/converted)
|
||||
- [ ] Configure proper permissions
|
||||
|
||||
### API Changes
|
||||
- [ ] Modify upload endpoint to save to shared storage
|
||||
- [ ] Create DB records for uploads
|
||||
- [ ] Add conversion job enqueueing
|
||||
- [ ] Implement file download endpoint with status checking
|
||||
- [ ] Add status API for monitoring
|
||||
- [ ] Implement cache invalidation on file update
|
||||
|
||||
### Worker Setup
|
||||
- [ ] Create worker script/module
|
||||
- [ ] Implement Gotenberg API calls
|
||||
- [ ] Add error handling and retry logic
|
||||
- [ ] Set up logging and monitoring
|
||||
- [ ] Handle timeouts and failures
|
||||
|
||||
### Docker Configuration
|
||||
- [ ] Add Gotenberg container to docker-compose.yml
|
||||
- [ ] Add Redis container to docker-compose.yml
|
||||
- [ ] Configure worker container
|
||||
- [ ] Set up shared volume mounts
|
||||
- [ ] Configure environment variables
|
||||
- [ ] Set up container dependencies
|
||||
- [ ] Configure resource limits for Gotenberg
|
||||
|
||||
### Client Updates
|
||||
- [ ] Modify client to check conversion status
|
||||
- [ ] Implement retry logic for pending conversions
|
||||
- [ ] Add loading/waiting screens
|
||||
- [ ] Implement error handling
|
||||
|
||||
### Testing
|
||||
- [ ] Test upload → conversion → download flow
|
||||
- [ ] Test multiple concurrent conversions
|
||||
- [ ] Test error handling (corrupted PPTX, etc.)
|
||||
- [ ] Test Gotenberg timeout handling
|
||||
- [ ] Test cache invalidation on file update
|
||||
- [ ] Load test with multiple clients
|
||||
- [ ] Test Gotenberg health checks
|
||||
|
||||
### Monitoring & Operations
|
||||
- [ ] Set up logging for conversions
|
||||
- [ ] Monitor Gotenberg health endpoint
|
||||
- [ ] Implement cleanup job for old files
|
||||
- [ ] Add metrics for conversion times
|
||||
- [ ] Set up alerts for failed conversions
|
||||
- [ ] Monitor shared storage disk usage
|
||||
- [ ] Document backup procedures
|
||||
|
||||
### Security
|
||||
- [ ] Validate file types before conversion
|
||||
- [ ] Set file size limits
|
||||
- [ ] Sanitize filenames
|
||||
- [ ] Implement rate limiting
|
||||
- [ ] Secure inter-container communication
|
||||
|
||||
## 14. Gotenberg Configuration Options
|
||||
|
||||
### **Environment Variables:**
|
||||
|
||||
```yaml
|
||||
gotenberg:
|
||||
image: gotenberg/gotenberg:8
|
||||
environment:
|
||||
# API Configuration
|
||||
- GOTENBERG_API_TIMEOUT=300s
|
||||
- GOTENBERG_API_PORT=3000
|
||||
|
||||
# Logging
|
||||
- GOTENBERG_LOG_LEVEL=info # debug, info, warn, error
|
||||
|
||||
# LibreOffice
|
||||
- GOTENBERG_LIBREOFFICE_DISABLE_ROUTES=false
|
||||
- GOTENBERG_LIBREOFFICE_AUTO_START=true
|
||||
|
||||
# Chromium (if needed for HTML/Markdown)
|
||||
- GOTENBERG_CHROMIUM_DISABLE_ROUTES=true # Disable if not needed
|
||||
|
||||
# Resource limits
|
||||
- GOTENBERG_LIBREOFFICE_MAX_QUEUE_SIZE=100
|
||||
```
|
||||
|
||||
### **Custom Gotenberg Configuration:**
|
||||
|
||||
For advanced configurations, create a `gotenberg.yml`:
|
||||
|
||||
```yaml
|
||||
api:
|
||||
timeout: 300s
|
||||
port: 3000
|
||||
|
||||
libreoffice:
|
||||
autoStart: true
|
||||
maxQueueSize: 100
|
||||
|
||||
chromium:
|
||||
disableRoutes: true
|
||||
```
|
||||
|
||||
Mount it in docker-compose:
|
||||
|
||||
```yaml
|
||||
gotenberg:
|
||||
image: gotenberg/gotenberg:8
|
||||
volumes:
|
||||
- ./gotenberg.yml:/etc/gotenberg/config.yml:ro
|
||||
- shared-storage:/shared
|
||||
```
|
||||
|
||||
## 15. Troubleshooting
|
||||
|
||||
### **Common Issues:**
|
||||
|
||||
**Gotenberg timeout:**
|
||||
```python
|
||||
# Increase timeout for large files
|
||||
response = requests.post(
|
||||
f'{GOTENBERG_URL}/forms/libreoffice/convert',
|
||||
files=files,
|
||||
timeout=600 # 10 minutes for large PPTX
|
||||
)
|
||||
```
|
||||
|
||||
**Memory issues:**
|
||||
```yaml
|
||||
# Increase Gotenberg memory limit
|
||||
gotenberg:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
```
|
||||
|
||||
**File permission issues:**
|
||||
```bash
|
||||
# Ensure proper permissions on shared volume
|
||||
chmod -R 755 /shared
|
||||
chown -R 1000:1000 /shared
|
||||
```
|
||||
|
||||
**Gotenberg not responding:**
|
||||
```python
|
||||
# Check health before conversion
|
||||
def ensure_gotenberg_healthy():
|
||||
try:
|
||||
response = requests.get(f'{GOTENBERG_URL}/health', timeout=5)
|
||||
if response.status_code != 200:
|
||||
raise Exception("Gotenberg unhealthy")
|
||||
except Exception as e:
|
||||
logger.error(f"Gotenberg health check failed: {e}")
|
||||
raise
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**This architecture provides a production-ready, scalable solution using Gotenberg as a specialized conversion service with efficient file sharing via Docker volumes!**
|
||||
|
||||
## 16. Best Practices Specific to Infoscreen
|
||||
|
||||
- Idempotency by content: Always compute a SHA‑256 of the uploaded source and include it in the unique key (source_event_media_id, target_format, file_hash). This prevents duplicate work for identical content and auto-busts cache on change.
|
||||
- Strict MIME/type validation: Accept only .ppt, .pptx, .odp for conversion. Reject unknown types early. Consider reading the first bytes (magic) for extra safety.
|
||||
- Bounded retries with jitter: Retry conversions on transient HTTP 5xx or timeouts up to N times with exponential backoff. Do not retry on 4xx or clear user errors.
|
||||
- Output naming: Derive deterministic output paths under media/converted/, e.g., <basename>.pdf. Ensure no path traversal and sanitize names.
|
||||
- Timeouts and size limits: Enforce server-side max upload size and per-job conversion timeout (e.g., 10 minutes). Return clear errors for oversized/long-running files.
|
||||
- Isolation and quotas: Set CPU/memory limits for Gotenberg; consider a concurrency cap per worker to avoid DB starvation.
|
||||
- Health probes before work: Check Gotenberg /health prior to enqueue spikes; fail-fast to avoid queue pile-ups when Gotenberg is down.
|
||||
- Observability: Log job IDs, file hashes, durations, and sizes. Expose a small /api/conversions/status summary for operational visibility.
|
||||
- Cleanup policy: Periodically delete orphaned conversions (media deleted) and failed jobs older than X days. Keep successful PDFs aligned with DB rows.
|
||||
- Security: Never trust client paths; always resolve relative to the known media root. Do not expose the shared volume directly; serve via API only.
|
||||
- Backpressure: If queue length exceeds a threshold, surface 503/“try later” on new uploads or pause enqueue to protect the system.
|
||||
8
scheduler/Dockerfile
Normal file
8
scheduler/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM python:3.13-slim
|
||||
WORKDIR /app
|
||||
COPY scheduler/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY scheduler/ ./scheduler
|
||||
COPY models/ ./models
|
||||
ENV PYTHONPATH=/app
|
||||
CMD ["python", "-m", "scheduler.scheduler"]
|
||||
113
scheduler/db_utils.py
Normal file
113
scheduler/db_utils.py
Normal file
@@ -0,0 +1,113 @@
|
||||
# scheduler/db_utils.py
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import sessionmaker, joinedload
|
||||
from sqlalchemy import create_engine
|
||||
from models.models import Event, EventMedia
|
||||
|
||||
load_dotenv('/workspace/.env')
|
||||
|
||||
# DB-URL aus Umgebungsvariable oder Fallback
|
||||
DB_CONN = os.environ.get("DB_CONN", "mysql+pymysql://user:password@db/dbname")
|
||||
engine = create_engine(DB_CONN)
|
||||
Session = sessionmaker(bind=engine)
|
||||
|
||||
# Base URL from .env for file URLs
|
||||
API_BASE_URL = os.environ.get("API_BASE_URL", "http://server:8000")
|
||||
|
||||
|
||||
def get_active_events(start: datetime, end: datetime, group_id: int = None):
|
||||
session = Session()
|
||||
try:
|
||||
# Now this will work with the relationship defined
|
||||
query = session.query(Event).options(
|
||||
joinedload(Event.event_media)
|
||||
).filter(Event.is_active == True)
|
||||
|
||||
if start and end:
|
||||
query = query.filter(Event.start < end, Event.end > start)
|
||||
if group_id:
|
||||
query = query.filter(Event.group_id == group_id)
|
||||
|
||||
events = query.all()
|
||||
|
||||
formatted_events = []
|
||||
for event in events:
|
||||
formatted_event = format_event_with_media(event)
|
||||
formatted_events.append(formatted_event)
|
||||
|
||||
return formatted_events
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def format_event_with_media(event):
|
||||
"""Transform Event + EventMedia into client-expected format"""
|
||||
event_dict = {
|
||||
"id": event.id,
|
||||
"title": event.title,
|
||||
"start": str(event.start),
|
||||
"end": str(event.end),
|
||||
"group_id": event.group_id,
|
||||
}
|
||||
|
||||
# Now you can directly access event.event_media
|
||||
import logging
|
||||
if event.event_media:
|
||||
media = event.event_media
|
||||
|
||||
if event.event_type.value == "presentation":
|
||||
event_dict["presentation"] = {
|
||||
"type": "slideshow",
|
||||
"files": [],
|
||||
"slide_interval": event.slideshow_interval or 5000,
|
||||
"auto_advance": True
|
||||
}
|
||||
|
||||
# Debug: log media_type
|
||||
logging.debug(
|
||||
f"[Scheduler] EventMedia id={media.id} media_type={getattr(media.media_type, 'value', str(media.media_type))}")
|
||||
|
||||
# Check for PDF conversion for ppt/pptx/odp
|
||||
from sqlalchemy.orm import scoped_session
|
||||
from models.models import Conversion, ConversionStatus
|
||||
session = scoped_session(Session)
|
||||
pdf_url = None
|
||||
if getattr(media.media_type, 'value', str(media.media_type)) in ("ppt", "pptx", "odp"):
|
||||
conversion = session.query(Conversion).filter_by(
|
||||
source_event_media_id=media.id,
|
||||
target_format="pdf",
|
||||
status=ConversionStatus.ready
|
||||
).order_by(Conversion.completed_at.desc()).first()
|
||||
logging.debug(
|
||||
f"[Scheduler] Conversion lookup for media_id={media.id}: found={bool(conversion)}, path={getattr(conversion, 'target_path', None) if conversion else None}")
|
||||
if conversion and conversion.target_path:
|
||||
# Serve via /api/files/converted/<path>
|
||||
pdf_url = f"{API_BASE_URL}/api/files/converted/{conversion.target_path}"
|
||||
session.remove()
|
||||
|
||||
if pdf_url:
|
||||
filename = os.path.basename(pdf_url)
|
||||
event_dict["presentation"]["files"].append({
|
||||
"name": filename,
|
||||
"url": pdf_url,
|
||||
"checksum": None,
|
||||
"size": None
|
||||
})
|
||||
logging.info(
|
||||
f"[Scheduler] Using converted PDF for event_media_id={media.id}: {pdf_url}")
|
||||
elif media.file_path:
|
||||
filename = os.path.basename(media.file_path)
|
||||
event_dict["presentation"]["files"].append({
|
||||
"name": filename,
|
||||
"url": f"{API_BASE_URL}/api/files/{media.id}/{filename}",
|
||||
"checksum": None,
|
||||
"size": None
|
||||
})
|
||||
logging.info(
|
||||
f"[Scheduler] Using original file for event_media_id={media.id}: {filename}")
|
||||
|
||||
# Add other event types...
|
||||
|
||||
return event_dict
|
||||
4
scheduler/requirements.txt
Normal file
4
scheduler/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
paho-mqtt
|
||||
sqlalchemy
|
||||
pymysql
|
||||
python-dotenv
|
||||
112
scheduler/scheduler.py
Normal file
112
scheduler/scheduler.py
Normal file
@@ -0,0 +1,112 @@
|
||||
# scheduler/scheduler.py
|
||||
|
||||
import os
|
||||
import logging
|
||||
from .db_utils import get_active_events
|
||||
import paho.mqtt.client as mqtt
|
||||
import json
|
||||
import datetime
|
||||
import time
|
||||
|
||||
# Logging-Konfiguration
|
||||
ENV = os.getenv("ENV", "development")
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG" if ENV == "development" else "INFO")
|
||||
LOG_PATH = os.path.join(os.path.dirname(__file__), "scheduler.log")
|
||||
os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
|
||||
log_handlers = []
|
||||
if ENV == "production":
|
||||
from logging.handlers import RotatingFileHandler
|
||||
log_handlers.append(RotatingFileHandler(
|
||||
LOG_PATH, maxBytes=2*1024*1024, backupCount=5, encoding="utf-8"))
|
||||
else:
|
||||
log_handlers.append(logging.FileHandler(LOG_PATH, encoding="utf-8"))
|
||||
if os.getenv("DEBUG_MODE", "1" if ENV == "development" else "0") in ("1", "true", "True"):
|
||||
log_handlers.append(logging.StreamHandler())
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=log_handlers
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
# Fix für die veraltete API - explizit callback_api_version setzen
|
||||
client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2)
|
||||
client.reconnect_delay_set(min_delay=1, max_delay=30)
|
||||
|
||||
POLL_INTERVAL = 30 # Sekunden, Empfehlung für seltene Änderungen
|
||||
# 0 = aus; z.B. 600 für alle 10 Min
|
||||
REFRESH_SECONDS = int(os.getenv("REFRESH_SECONDS", "0"))
|
||||
last_payloads = {} # group_id -> payload
|
||||
last_published_at = {} # group_id -> epoch seconds
|
||||
|
||||
# Beim (Re-)Connect alle bekannten retained Payloads erneut senden
|
||||
def on_connect(client, userdata, flags, reasonCode, properties=None):
|
||||
logging.info(
|
||||
f"MQTT connected (reasonCode={reasonCode}) - republishing {len(last_payloads)} groups")
|
||||
for gid, payload in last_payloads.items():
|
||||
topic = f"infoscreen/events/{gid}"
|
||||
client.publish(topic, payload, retain=True)
|
||||
|
||||
client.on_connect = on_connect
|
||||
|
||||
client.connect("mqtt", 1883)
|
||||
client.loop_start()
|
||||
|
||||
while True:
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
# Hole alle aktiven Events (bereits formatierte Dictionaries)
|
||||
events = get_active_events(now, now)
|
||||
|
||||
# Gruppiere Events nach group_id
|
||||
groups = {}
|
||||
for event in events:
|
||||
gid = event.get("group_id")
|
||||
if gid not in groups:
|
||||
groups[gid] = []
|
||||
# Event ist bereits ein Dictionary im gewünschten Format
|
||||
groups[gid].append(event)
|
||||
|
||||
# Sende pro Gruppe die Eventliste als retained Message, nur bei Änderung
|
||||
for gid, event_list in groups.items():
|
||||
# stabile Reihenfolge, um unnötige Publishes zu vermeiden
|
||||
event_list.sort(key=lambda e: (e.get("start"), e.get("id")))
|
||||
payload = json.dumps(
|
||||
event_list, sort_keys=True, separators=(",", ":"))
|
||||
topic = f"infoscreen/events/{gid}"
|
||||
|
||||
should_send = (last_payloads.get(gid) != payload)
|
||||
if not should_send and REFRESH_SECONDS:
|
||||
last_ts = last_published_at.get(gid, 0)
|
||||
if time.time() - last_ts >= REFRESH_SECONDS:
|
||||
should_send = True
|
||||
|
||||
if should_send:
|
||||
result = client.publish(topic, payload, retain=True)
|
||||
if result.rc != mqtt.MQTT_ERR_SUCCESS:
|
||||
logging.error(
|
||||
f"Fehler beim Publish für Gruppe {gid}: {mqtt.error_string(result.rc)}")
|
||||
else:
|
||||
logging.info(f"Events für Gruppe {gid} gesendet")
|
||||
last_payloads[gid] = payload
|
||||
last_published_at[gid] = time.time()
|
||||
|
||||
# Entferne Gruppen, die nicht mehr existieren (leere retained Message senden)
|
||||
for gid in list(last_payloads.keys()):
|
||||
if gid not in groups:
|
||||
topic = f"infoscreen/events/{gid}"
|
||||
result = client.publish(topic, payload="[]", retain=True)
|
||||
if result.rc != mqtt.MQTT_ERR_SUCCESS:
|
||||
logging.error(
|
||||
f"Fehler beim Entfernen für Gruppe {gid}: {mqtt.error_string(result.rc)}")
|
||||
else:
|
||||
logging.info(
|
||||
f"Events für Gruppe {gid} entfernt (leere retained Message gesendet)")
|
||||
del last_payloads[gid]
|
||||
last_published_at.pop(gid, None)
|
||||
|
||||
time.sleep(POLL_INTERVAL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
58
server-setup-script.sh
Executable file
58
server-setup-script.sh
Executable file
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
# Ubuntu Server Setup Script for Infoscreen Development
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Setting up Ubuntu Server for Infoscreen Development..."
|
||||
|
||||
# Update system
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
|
||||
# Install essential tools
|
||||
sudo apt install -y git curl wget vim nano htop
|
||||
|
||||
# Install Docker (official installation method)
|
||||
echo "📦 Installing Docker..."
|
||||
|
||||
# Remove old Docker packages
|
||||
sudo apt remove -y docker docker-engine docker.io containerd runc 2>/dev/null || true
|
||||
|
||||
# Install Docker dependencies
|
||||
sudo apt install -y ca-certificates curl gnupg lsb-release
|
||||
|
||||
# Add Docker GPG key
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
|
||||
# Add Docker repository
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
|
||||
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
|
||||
# Install Docker
|
||||
sudo apt update
|
||||
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
|
||||
# Start and enable Docker
|
||||
sudo systemctl enable docker
|
||||
sudo systemctl start docker
|
||||
|
||||
# Add user to docker group
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# Install Node.js (for dashboard development)
|
||||
echo "📦 Installing Node.js..."
|
||||
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
|
||||
sudo apt install -y nodejs
|
||||
|
||||
# Verify installations
|
||||
echo "✅ Verifying installations..."
|
||||
echo "Docker version: $(docker --version)"
|
||||
echo "Docker Compose version: $(docker compose version)"
|
||||
echo "Node.js version: $(node --version)"
|
||||
echo "npm version: $(npm --version)"
|
||||
|
||||
echo ""
|
||||
echo "🎉 Server setup complete!"
|
||||
echo "⚠️ Please log out and log back in for Docker group changes to take effect."
|
||||
echo "💡 You can now clone your repository and start development."
|
||||
61
server/Dockerfile
Normal file
61
server/Dockerfile
Normal file
@@ -0,0 +1,61 @@
|
||||
# server/Dockerfile
|
||||
# 🔧 OPTIMIERT: Multi-Stage-Build für ein minimales und sicheres Produktions-Image
|
||||
|
||||
# Stage 1: Builder - Installiert Abhängigkeiten
|
||||
FROM python:3.13-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Installiert nur die für den Build notwendigen Systemabhängigkeiten
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
libmariadb-dev-compat libmariadb-dev gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Kopiert nur die requirements.txt, um den Docker-Cache optimal zu nutzen
|
||||
COPY /server/requirements.txt .
|
||||
|
||||
# Installiert die Python-Pakete in ein separates Verzeichnis
|
||||
RUN pip install --no-cache-dir --prefix="/install" -r requirements.txt
|
||||
|
||||
# Stage 2: Final - Das eigentliche Produktions-Image
|
||||
FROM python:3.13-slim
|
||||
|
||||
# --- Arbeitsverzeichnis ---
|
||||
WORKDIR /app
|
||||
|
||||
# Installiert nur die für die Laufzeit notwendigen Systemabhängigkeiten
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
libmariadb-dev-compat locales curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# --- Locale konfigurieren ---
|
||||
RUN sed -i 's/# de_DE.UTF-8 UTF-8/de_DE.UTF-8 UTF-8/' /etc/locale.gen \
|
||||
&& locale-gen
|
||||
ENV LANG=de_DE.UTF-8 \
|
||||
LC_ALL=de_DE.UTF-8
|
||||
|
||||
# Kopiert die installierten Pakete aus der Builder-Stage
|
||||
COPY --from=builder /install /usr/local
|
||||
|
||||
# --- Applikationscode ---
|
||||
# Kopiert den Server-Code in das Arbeitsverzeichnis
|
||||
COPY server/ ./server
|
||||
COPY models/ ./models
|
||||
|
||||
# --- 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 die API exposed ---
|
||||
EXPOSE 8000
|
||||
|
||||
# --- Startbefehl für Gunicorn ---
|
||||
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "server.wsgi:app"]
|
||||
|
||||
42
server/Dockerfile.dev
Normal file
42
server/Dockerfile.dev
Normal file
@@ -0,0 +1,42 @@
|
||||
# Datei: server/Dockerfile.dev
|
||||
# 🔧 OPTIMIERT: Für die Entwicklung im Dev-Container
|
||||
# ==========================================
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
# Die Erstellung des non-root Users und die Locale-Konfiguration
|
||||
# sind für den Dev-Container nicht zwingend nötig, da VS Code sich als 'root'
|
||||
# verbindet (gemäß devcontainer.json). Sie schaden aber nicht.
|
||||
ARG USER_ID=1000
|
||||
ARG GROUP_ID=1000
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends locales curl git \
|
||||
&& groupadd -g ${GROUP_ID} infoscreen_taa \
|
||||
&& useradd -u ${USER_ID} -g ${GROUP_ID} --shell /bin/bash --create-home infoscreen_taa \
|
||||
&& 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/*
|
||||
|
||||
ENV LANG=de_DE.UTF-8 \
|
||||
LC_ALL=de_DE.UTF-8
|
||||
|
||||
# Setze das Arbeitsverzeichnis auf den Workspace-Root, passend zu den Mounts.
|
||||
WORKDIR /app
|
||||
|
||||
# Kopiere die Anforderungsdateien in das korrekte Unterverzeichnis.
|
||||
# ✅ KORRIGIERT: Pfade sind jetzt relativ zum Build-Kontext (dem 'server'-Verzeichnis)
|
||||
COPY server/requirements.txt server/requirements-dev.txt ./server/
|
||||
|
||||
# Installiere die Python-Abhängigkeiten
|
||||
RUN pip install --upgrade pip \
|
||||
&& pip install --no-cache-dir -r server/requirements.txt \
|
||||
&& pip install --no-cache-dir -r server/requirements-dev.txt
|
||||
|
||||
# Das Kopieren des Codes ist nicht nötig, da das Verzeichnis gemountet wird.
|
||||
|
||||
# Exponiere die Ports für die Flask API und den Debugger
|
||||
EXPOSE 8000 5678
|
||||
|
||||
# Der Startbefehl wird in der docker-compose.override.yml definiert.
|
||||
# Ein Standard-CMD dient als Fallback.
|
||||
CMD ["python", "-m", "debugpy", "--listen", "0.0.0.0:5678", "-m", "flask", "run", "--host=0.0.0.0", "--port=8000"]
|
||||
|
||||
8
server/__init__.py
Normal file
8
server/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Server package initializer.
|
||||
|
||||
Expose submodules required by external importers (e.g., RQ string paths).
|
||||
"""
|
||||
|
||||
# Ensure 'server.worker' is available as an attribute of the 'server' package
|
||||
# so that RQ can resolve 'server.worker.convert_event_media_to_pdf'.
|
||||
from . import worker # noqa: F401
|
||||
141
server/alembic.ini
Normal file
141
server/alembic.ini
Normal file
@@ -0,0 +1,141 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
# sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
1
server/alembic/README
Normal file
1
server/alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
115
server/alembic/env.py
Normal file
115
server/alembic/env.py
Normal file
@@ -0,0 +1,115 @@
|
||||
# isort: skip_file
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy import engine_from_config
|
||||
from logging.config import fileConfig
|
||||
from dotenv import load_dotenv
|
||||
from models.models import Base
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, '/workspace')
|
||||
print("sys.path:", sys.path)
|
||||
print("models dir exists:", os.path.isdir('/workspace/models'))
|
||||
print("models/models.py exists:", os.path.isfile('/workspace/models/models.py'))
|
||||
print("models/__init__.py exists:",
|
||||
os.path.isfile('/workspace/models/__init__.py'))
|
||||
|
||||
print("sys.path:", sys.path)
|
||||
print("models dir exists:", os.path.isdir('/workspace/models'))
|
||||
print("models/models.py exists:", os.path.isfile('/workspace/models/models.py'))
|
||||
print("models/__init__.py exists:",
|
||||
os.path.isfile('/workspace/models/__init__.py'))
|
||||
|
||||
# .env laden (optional)
|
||||
env_path = os.path.abspath(os.path.join(
|
||||
os.path.dirname(__file__), '../../.env'))
|
||||
print(f"Loading environment variables from: {env_path}")
|
||||
load_dotenv(env_path)
|
||||
|
||||
DB_CONN = os.getenv("DB_CONN")
|
||||
if DB_CONN:
|
||||
DATABASE_URL = DB_CONN
|
||||
else:
|
||||
# Datenbank-Zugangsdaten aus .env
|
||||
DB_USER = os.getenv("DB_USER")
|
||||
DB_PASSWORD = os.getenv("DB_PASSWORD")
|
||||
DB_HOST = os.getenv("DB_HOST", "db") # Default jetzt 'db'
|
||||
DB_PORT = os.getenv("DB_PORT", "3306")
|
||||
DB_NAME = os.getenv("DB_NAME")
|
||||
DATABASE_URL = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
print(f"Using DATABASE_URL: {DATABASE_URL}")
|
||||
|
||||
config.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
28
server/alembic/script.py.mako
Normal file
28
server/alembic/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Rename location to description in client_groups, add description to clients
|
||||
|
||||
Revision ID: 0c47280d3e2d
|
||||
Revises: 3a09ef909689
|
||||
Create Date: 2025-07-16 08:47:00.355445
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0c47280d3e2d'
|
||||
down_revision: Union[str, None] = '3a09ef909689'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('client_groups', sa.Column('description', sa.String(length=255), nullable=True))
|
||||
op.drop_column('client_groups', 'location')
|
||||
op.add_column('clients', sa.Column('description', sa.String(length=255), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('clients', 'description')
|
||||
op.add_column('client_groups', sa.Column('location', mysql.VARCHAR(length=100), nullable=True))
|
||||
op.drop_column('client_groups', 'description')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Update clients table for new fields
|
||||
|
||||
Revision ID: 207f5b190f93
|
||||
Revises: 3d15c3cac7b6
|
||||
Create Date: 2025-07-15 14:12:42.427274
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '207f5b190f93'
|
||||
down_revision: Union[str, None] = '3d15c3cac7b6'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('clients', sa.Column('hardware_token', sa.String(length=64), nullable=True))
|
||||
op.add_column('clients', sa.Column('ip', sa.String(length=45), nullable=True))
|
||||
op.add_column('clients', sa.Column('type', sa.String(length=50), nullable=True))
|
||||
op.add_column('clients', sa.Column('hostname', sa.String(length=100), nullable=True))
|
||||
op.add_column('clients', sa.Column('os_version', sa.String(length=100), nullable=True))
|
||||
op.add_column('clients', sa.Column('software_version', sa.String(length=100), nullable=True))
|
||||
op.add_column('clients', sa.Column('macs', sa.String(length=255), nullable=True))
|
||||
op.add_column('clients', sa.Column('model', sa.String(length=100), nullable=True))
|
||||
op.drop_index(op.f('ix_clients_hardware_hash'), table_name='clients')
|
||||
op.drop_index(op.f('ix_clients_ip_address'), table_name='clients')
|
||||
op.drop_column('clients', 'location')
|
||||
op.drop_column('clients', 'hardware_hash')
|
||||
op.drop_column('clients', 'ip_address')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('clients', sa.Column('ip_address', mysql.VARCHAR(length=45), nullable=True))
|
||||
op.add_column('clients', sa.Column('hardware_hash', mysql.VARCHAR(length=64), nullable=False))
|
||||
op.add_column('clients', sa.Column('location', mysql.VARCHAR(length=100), nullable=True))
|
||||
op.create_index(op.f('ix_clients_ip_address'), 'clients', ['ip_address'], unique=False)
|
||||
op.create_index(op.f('ix_clients_hardware_hash'), 'clients', ['hardware_hash'], unique=False)
|
||||
op.drop_column('clients', 'model')
|
||||
op.drop_column('clients', 'macs')
|
||||
op.drop_column('clients', 'software_version')
|
||||
op.drop_column('clients', 'os_version')
|
||||
op.drop_column('clients', 'hostname')
|
||||
op.drop_column('clients', 'type')
|
||||
op.drop_column('clients', 'ip')
|
||||
op.drop_column('clients', 'hardware_token')
|
||||
# ### end Alembic commands ###
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user