add forgejo, add jupyter

This commit is contained in:
Aron Petau 2025-11-05 14:32:21 +01:00
parent a6d498bda0
commit 98417edd8b
23 changed files with 562 additions and 261 deletions

BIN
.DS_Store vendored

Binary file not shown.

View file

@ -45,9 +45,9 @@ BOOKSTACK_APP_KEY=base64:YOUR_BOOKSTACK_KEY_HERE
# ======================================== # ========================================
DB_HOST=bookstack-mariadb DB_HOST=bookstack-mariadb
DB_PORT=3306 DB_PORT=3306
DB_DATABASE=bookstack BOOKSTACK_DB_DATABASE=bookstack
DB_USERNAME=bookstack BOOKSTACK_DB_USERNAME=bookstack
DB_PASSWORD=your_secure_database_password_here BOOKSTACK_DB_PASSWORD=your_secure_database_password_here
# MariaDB root password # MariaDB root password
MARIADB_ROOT_PASSWORD=your_secure_root_password_here MARIADB_ROOT_PASSWORD=your_secure_root_password_here

View file

@ -71,6 +71,13 @@ BOOKSTACK_PORT=6875
# BookStack/DB configs (see docker-compose.yml for full list) # BookStack/DB configs (see docker-compose.yml for full list)
``` ```
Note: this repository also contains a local `.env` with example credentials (do NOT commit real secrets). Current repository `.env` contents:
```properties
MATRIX_USER=einszwovier_bot
MATRIX_PASS=einszwo4
```
Prefer injecting secrets via CI environment variables or a secure vault for production. Add `MATRIX_ROOM` and/or `MATRIX_HOMESERVER` when testing Matrix flows.
### Debugging Matrix Integration ### Debugging Matrix Integration
Use `get_room_id.py` to discover Matrix room IDs: Use `get_room_id.py` to discover Matrix room IDs:
```python ```python

2
.gitignore vendored
View file

@ -103,3 +103,5 @@ dmypy.json
*.db-wal *.db-wal
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
.github
.venv

View file

@ -1,231 +0,0 @@
# Portainer Quick Reference
## 🚀 Quick Start
### Access Portainer
```
http://localhost:9000
or
http://einszwovier.local:9000
```
### First Login
1. Create admin account
2. Select "Local" environment
3. Done!
## 📊 Stack Overview: studio-einszwovier
| Service | Port | Health Check | Purpose |
|---------|------|--------------|---------|
| web | 80 | ✅ | PDF Cost Calculator |
| bookstack | 6875 | ✅ | Documentation Wiki |
| bookstack-mariadb | - | ✅ | Database |
| synapse | 8008 | ✅ | Matrix Server |
| ollama | 11434 | ✅ | Local LLM |
| open-webui | 8080 | ✅ | LLM Interface |
| watchtower | - | ✅ | Auto-Updates |
| portainer | 9000 | ✅ | Management UI |
## 🎯 Common Tasks
### View All Services
**Portainer**: Stacks → studio-einszwovier
**CLI**: `docker-compose ps`
### Restart a Service
**Portainer**: Containers → [service] → Restart
**CLI**: `docker-compose restart [service]`
### View Logs
**Portainer**: Containers → [service] → Logs
**CLI**: `docker-compose logs -f [service]`
### Check Health Status
**Portainer**: Containers → Look for 🟢/🔴 indicator
**CLI**: `docker-compose ps` (shows (healthy) or (unhealthy))
### Stop Everything
**Portainer**: Stacks → studio-einszwovier → Stop
**CLI**: `docker-compose stop`
### Start Everything
**Portainer**: Stacks → studio-einszwovier → Start
**CLI**: `docker-compose start`
### Update a Service
**Portainer**: Containers → [service] → Recreate
**CLI**: `docker-compose pull [service] && docker-compose up -d [service]`
## 🔍 Troubleshooting
### Service Shows 🔴 Unhealthy
1. Click container → Logs
2. Look for errors
3. Check health check output in Inspect tab
4. Restart if needed
### Can't Access Portainer
```bash
docker-compose restart portainer
# Wait 30 seconds
# Try again at http://localhost:9000
```
### Service Won't Start
1. Check Portainer logs for the service
2. Look for dependency issues (red containers)
3. Check resource limits (Stats tab)
4. Verify environment variables in Container → Env
## 📋 Health Check Endpoints
Test manually if needed:
```bash
# Web App
curl http://localhost/
# BookStack
curl http://localhost:6875/
# Matrix Synapse
curl http://localhost:8008/health
# Ollama
curl http://localhost:11434/api/tags
# Open WebUI
curl http://localhost:8080/
# Portainer
curl http://localhost:9000/api/system/status
```
## 🏷️ Labels in Portainer
All services are tagged with:
- **description**: What it does
- **maintainer**: Studio EinsZwoVier
- **watchtower.enable**: Auto-update enabled
Filter by labels in Portainer UI!
## 🔄 Auto-Updates (Watchtower)
- **Schedule**: Every 24 hours
- **Action**: Pulls latest images and recreates containers
- **Which services**: Only those with `watchtower.enable=true` label
- **View updates**: Containers → watchtower → Logs
### Disable Auto-Update for a Service
Edit docker-compose.yml, remove:
```yaml
labels:
- "com.centurylinklabs.watchtower.enable=true"
```
## 📈 Resource Monitoring
**Portainer**: Container → Stats
Shows:
- CPU usage (%)
- Memory usage (MB / GB)
- Network I/O
- Block I/O
**Limits Set**:
- web: 1GB RAM, 1 CPU
- ollama: 8GB RAM, 4 CPU
## 🌐 Network: einszwovier_network
All services communicate on this network.
**View**: Networks → einszwovier_network → Connected containers
## 💾 Volumes
| Volume | Used By | Purpose |
|--------|---------|---------|
| portainer_data | portainer | Portainer config |
| ./data/uploads | web | PDF uploads |
| ./bookstack/* | bookstack | Wiki data |
| ./matrix/data | synapse | Matrix data |
| ./ollama | ollama | LLM models |
| ./open-webui | open-webui | Chat history |
**Backup**: See `backup.sh` script
## ⚙️ Useful Portainer Features
### Execute Shell Commands
1. Containers → [service] → Console
2. Select `/bin/bash` or `/bin/sh`
3. Click Connect
4. Run commands
### View Container Config
1. Containers → [service] → Inspect
2. See full JSON configuration
3. Copy environment variables
4. Check volume mounts
### Duplicate Container
1. Containers → [service] → Duplicate/Edit
2. Modify settings
3. Deploy as new container
### Container Template
1. App Templates → Custom Templates
2. Create from existing container
3. Reuse configuration
## 🎨 Status Icons
| Icon | Meaning | Action |
|------|---------|--------|
| 🟢 | Healthy | None needed |
| 🟡 | Starting | Wait (within start_period) |
| 🔴 | Unhealthy | Check logs |
| ⚫ | Stopped | Start container |
| 🔄 | Restarting | Wait or investigate |
## 📞 Quick Help
### "I can't find my containers!"
- Go to **Home**
- Select **Local** environment
- Go to **Stacks** → studio-einszwovier
### "Health checks keep failing"
- Increase `start_period` in docker-compose.yml
- Check service logs for actual errors
- Verify network connectivity
### "Portainer shows wrong status"
- Refresh page (F5)
- Check "Last Updated" timestamp
- Restart Portainer if stale
## 🔐 Security Notes
- Portainer admin password is set on first login (save it!)
- Docker socket mounted = full control (use carefully)
- HTTPS available on port 9443 (configure in Portainer settings)
## 📱 Mobile Access
Portainer works great on mobile:
1. Open browser on phone
2. Navigate to `http://[server-ip]:9000`
3. Same features as desktop!
## ✅ Daily Checklist
- [ ] All services showing 🟢 healthy
- [ ] No unusual CPU/memory spikes
- [ ] Recent watchtower update logs look good
- [ ] No red error logs in critical services
**Portainer makes this a 30-second check!**

View file

@ -11,17 +11,21 @@
## 🎯 Features ## 🎯 Features
### Core Application ### Core Application
- **📄 PDF Cost Calculator**: Automated ink coverage analysis with color/B&W detection - **📄 PDF Cost Calculator**: Automated ink coverage analysis with color/B&W detection
- **🖼️ Course Management**: Dynamic course list with image gallery and modal viewer - **🖼️ Course Management**: Dynamic course list with image gallery and modal viewer
- **📊 Cost Estimation**: Per-page breakdown with configurable rates (€/m²) - **📊 Cost Estimation**: Per-page breakdown with configurable rates (€/m²)
- **💬 Matrix Integration**: Automatic order submission to Matrix room - **💬 Matrix Integration**: Automatic order submission to Matrix room
### Integrated Services ### Integrated Services
- **📚 BookStack Wiki**: Documentation and knowledge base (port 6875) - **📚 BookStack Wiki**: Documentation and knowledge base (port 6875)
- **🤖 Ollama + Open WebUI**: Local LLM chatbot interface (port 8080) - **🤖 Ollama + Open WebUI**: Local LLM chatbot interface (port 8080)
- **📨 Matrix Synapse**: Print order collection and payment tracking (port 8008) - **📨 Matrix Synapse**: Print order collection and payment tracking (port 8008)
- **🐳 Portainer**: Container management dashboard (port 9000) - **🐳 Portainer**: Container management dashboard (port 9000)
- **🔄 Watchtower**: Automatic container updates every 24 hours - **<EFBFBD> JupyterHub**: Multi-user Jupyter notebook server for drone programming (port 8001)
- **🦊 Forgejo**: Self-hosted Git service for code collaboration (port 3003)
- **<EFBFBD>🔄 Watchtower**: Automatic container updates every 24 hours
--- ---
@ -74,35 +78,55 @@ graph TB
OpenWebUI[💭 Open WebUI<br/>:8080] OpenWebUI[💭 Open WebUI<br/>:8080]
Portainer[🐳 Portainer<br/>:9000] Portainer[🐳 Portainer<br/>:9000]
Synapse[📨 Matrix Synapse<br/>:8008] Synapse[📨 Matrix Synapse<br/>:8008]
JupyterHub[📓 JupyterHub<br/>:8001<br/>Drone Programming]
Forgejo[🦊 Forgejo Git<br/>:3003<br/>Code Collaboration]
LP -.-> BookStack LP -.-> BookStack
LP -.-> OpenWebUI LP -.-> OpenWebUI
LP -.-> Portainer LP -.-> Portainer
LP -.-> JupyterHub
LP -.-> Forgejo
OpenWebUI --> Ollama OpenWebUI --> Ollama
MatrixRoom --> Synapse MatrixRoom --> Synapse
end end
subgraph "Educational Stack"
Notebooks[📒 Jupyter Notebooks<br/>Python Drone Code]
GitRepos[📦 Git Repositories<br/>Project Source Code]
DronePackages[🚁 djitellopy<br/>Tello SDK]
JupyterHub --> Notebooks
Forgejo --> GitRepos
Notebooks -.-> DronePackages
end
subgraph "Data Persistence" subgraph "Data Persistence"
Uploads[📁 data/uploads/<br/>PDF Files] Uploads[📁 data/uploads/<br/>PDF Files]
Courses[📋 data/courses.csv<br/>Course List] Courses[📋 data/courses.csv<br/>Course List]
MatrixDB[(🗄️ Matrix DB<br/>homeserver.db)] MatrixDB[(🗄️ Matrix DB<br/>homeserver.db)]
BookDB[(🗄️ BookStack DB<br/>MariaDB)] BookDB[(🗄️ BookStack DB<br/>MariaDB)]
JupyterVolumes[(💾 Jupyter Volumes<br/>User Workspaces)]
ForgejoData[(📚 Forgejo Data<br/>Git Repos)]
Upload --> Uploads Upload --> Uploads
CSV --> Courses CSV --> Courses
Synapse --> MatrixDB Synapse --> MatrixDB
BookStack --> BookDB BookStack --> BookDB
JupyterHub --> JupyterVolumes
Forgejo --> ForgejoData
end end
classDef webapp fill:#e3f2fd,stroke:#1976d2,stroke-width:2px classDef webapp fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
classDef service fill:#fff3e0,stroke:#f57c00,stroke-width:2px classDef service fill:#fff3e0,stroke:#f57c00,stroke-width:2px
classDef data fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px classDef data fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
classDef matrix fill:#e8f5e9,stroke:#388e3c,stroke-width:2px classDef matrix fill:#e8f5e9,stroke:#388e3c,stroke-width:2px
classDef edu fill:#fce4ec,stroke:#c2185b,stroke-width:2px
class LP,About,Cost,Upload,Analysis,Quote webapp class LP,About,Cost,Upload,Analysis,Quote webapp
class BookStack,Ollama,OpenWebUI,Portainer,Synapse service class BookStack,Ollama,OpenWebUI,Portainer,Synapse,JupyterHub,Forgejo service
class Uploads,Courses,MatrixDB,BookDB data class Uploads,Courses,MatrixDB,BookDB,JupyterVolumes,ForgejoData data
class MatrixRoom,PDF_Upload,Summary,CSV,Images,Modal matrix class MatrixRoom,PDF_Upload,Summary,CSV,Images,Modal matrix
class Notebooks,GitRepos,DronePackages edu
``` ```
--- ---
@ -113,47 +137,55 @@ graph TB
- **Docker** & **Docker Compose** installed - **Docker** & **Docker Compose** installed
- **Poppler** (for pdf2image - included in Docker) - **Poppler** (for pdf2image - included in Docker)
- **Port availability**: 80, 8008, 8080, 6875, 9000, 11434 - **Port availability**: 80, 3003, 6875, 8001, 8008, 8080, 9000, 11434
### Installation ### Installation
1. **Clone the repository:** 1. **Clone the repository:**
```bash ```bash
git clone https://github.com/arontaupe/124-webapp.git git clone https://github.com/arontaupe/124-webapp.git
cd 124-webapp cd 124-webapp
``` ```
2. **Configure environment:** 2. **Configure environment:**
```bash ```bash
cp .env.example .env cp .env.example .env
nano .env # Update SERVER_HOSTNAME and passwords nano .env # Update SERVER_HOSTNAME and passwords
``` ```
3. **Start the stack:** 3. **Start the stack:**
```bash ```bash
docker compose up -d --build docker compose up -d --build
``` ```
4. **Access services:** 4. **Access services:**
- Web App: http://localhost (or http://your-server) - Web App: <http://localhost> (or <http://your-server>)
- BookStack: http://localhost:6875 - BookStack: <http://localhost:6875>
- Open WebUI: http://localhost:8080 - Open WebUI: <http://localhost:8080>
- Portainer: http://localhost:9000 - Portainer: <http://localhost:9000>
- Matrix: http://localhost:8008 - Matrix: <http://localhost:8008>
- JupyterHub: <http://localhost:8001>
- Forgejo: <http://localhost:3003>
### First-Time Setup ### First-Time Setup
1. **Get Matrix Room ID:** 1. **Get Matrix Room ID:**
```bash ```bash
python get_room_id.py python get_room_id.py
``` ```
2. **Update .env with room ID:** 2. **Update .env with room ID:**
```bash ```bash
MATRIX_ROOM="!YourRoomID:${SERVER_HOSTNAME}" MATRIX_ROOM="!YourRoomID:${SERVER_HOSTNAME}"
``` ```
3. **Restart web container:** 3. **Restart web container:**
```bash ```bash
docker compose restart web docker compose restart web
``` ```
@ -167,12 +199,14 @@ Courses are managed via CSV file with optional images:
### Add a Course ### Add a Course
1. **Edit `data/courses.csv`:** 1. **Edit `data/courses.csv`:**
```csv ```csv
title,description,dates,offen_fuer,image title,description,dates,offen_fuer,image
My Course,Learn cool stuff,"Oct '25",Grade 10,/static/images/courses/my-course.jpg My Course,Learn cool stuff,"Oct '25",Grade 10,/static/images/courses/my-course.jpg
``` ```
2. **Add course image (optional):** 2. **Add course image (optional):**
```bash ```bash
cp my-image.jpg static/images/courses/my-course.jpg cp my-image.jpg static/images/courses/my-course.jpg
``` ```
@ -180,6 +214,7 @@ Courses are managed via CSV file with optional images:
3. **Changes apply immediately** (no restart needed) 3. **Changes apply immediately** (no restart needed)
### Course Image Features ### Course Image Features
- **Thumbnail view**: 80x80px next to course info - **Thumbnail view**: 80x80px next to course info
- **Hover zoom**: Expands to 200x200px on hover - **Hover zoom**: Expands to 200x200px on hover
- **Click to enlarge**: Opens full-size modal viewer - **Click to enlarge**: Opens full-size modal viewer
@ -215,6 +250,11 @@ OLLAMA_PORT=11434
OPENWEBUI_PORT=8080 OPENWEBUI_PORT=8080
BOOKSTACK_PORT=6875 BOOKSTACK_PORT=6875
PORTAINER_PORT=9000 PORTAINER_PORT=9000
JUPYTER_PORT=8001
FORGEJO_PORT=3003
# JupyterHub Authentication
JUPYTER_PASSWORD=einszwo4
``` ```
See [.env.example](.env.example) for full configuration. See [.env.example](.env.example) for full configuration.
@ -231,12 +271,15 @@ See [.env.example](.env.example) for full configuration.
| **open-webui** | ghcr.io/open-webui/open-webui | 8080 | LLM chat UI | 2 CPU, 2GB RAM | | **open-webui** | ghcr.io/open-webui/open-webui | 8080 | LLM chat UI | 2 CPU, 2GB RAM |
| **bookstack** | lscr.io/linuxserver/bookstack | 6875 | Documentation wiki | Default | | **bookstack** | lscr.io/linuxserver/bookstack | 6875 | Documentation wiki | Default |
| **bookstack-mariadb** | lscr.io/linuxserver/mariadb | 3306 | Database for BookStack | Default | | **bookstack-mariadb** | lscr.io/linuxserver/mariadb | 3306 | Database for BookStack | Default |
| **jupyterhub** | Custom (Python 3.13) | 8001 | Multi-user Jupyter notebooks | 2 CPU, 2GB RAM |
| **forgejo** | codeberg.org/forgejo/forgejo:11 | 3003, 222 | Git service (web, SSH) | Default |
| **portainer** | portainer/portainer-ce | 9000, 9443 | Container management | Default | | **portainer** | portainer/portainer-ce | 9000, 9443 | Container management | Default |
| **watchtower** | containrrr/watchtower | - | Auto-updates (24h) | Default | | **watchtower** | containrrr/watchtower | - | Auto-updates (24h) | Default |
### Health Checks ### Health Checks
All services have health checks configured: All services have health checks configured:
- **web**: `curl http://localhost:8000/` - **web**: `curl http://localhost:8000/`
- **synapse**: `curl http://localhost:8008/_matrix/static/` - **synapse**: `curl http://localhost:8008/_matrix/static/`
- **ollama**: `curl http://localhost:11434/` - **ollama**: `curl http://localhost:11434/`
@ -283,6 +326,7 @@ See [SECURITY.md](SECURITY.md) for comprehensive security guidelines.
``` ```
Creates timestamped backups of: Creates timestamped backups of:
- BookStack database & files - BookStack database & files
- Matrix homeserver data - Matrix homeserver data
- PDF uploads - PDF uploads
@ -302,11 +346,13 @@ Creates timestamped backups of:
This application is designed to be **fully portable**. To move to a new server: This application is designed to be **fully portable**. To move to a new server:
1. **On old server:** 1. **On old server:**
```bash ```bash
./backup.sh ./backup.sh
``` ```
2. **On new server:** 2. **On new server:**
```bash ```bash
git clone <repo-url> git clone <repo-url>
cd 124-webapp cd 124-webapp
@ -393,7 +439,7 @@ This project is part of Studio EinsZwoVier educational initiative.
Gabriele-von-Bülow-Gymnasium Gabriele-von-Bülow-Gymnasium
Tile-Brügge-Weg 63, 13509 Berlin (Tegel) Tile-Brügge-Weg 63, 13509 Berlin (Tegel)
- **Email**: einszwovier@gvb-gymnasium.de - **Email**: <einszwovier@gvb-gymnasium.de>
- **Team**: Aron Petau & Friedrich Weber - **Team**: Aron Petau & Friedrich Weber
- **Hours**: Tuesday - Thursday, 11:00 - 16:00 - **Hours**: Tuesday - Thursday, 11:00 - 16:00
- **Location**: Room 124 - **Location**: Room 124

View file

@ -19,9 +19,9 @@ echo "Working directory: $SCRIPT_DIR"
# 1. Backup BookStack database # 1. Backup BookStack database
echo "Backing up BookStack database..." echo "Backing up BookStack database..."
docker exec bookstack-mariadb mariadb-dump \ docker exec bookstack-mariadb mariadb-dump \
-u"${DB_USERNAME:-bookstack}" \ -u"${BOOKSTACK_DB_USERNAME:-bookstack}" \
-p"${DB_PASSWORD}" \ -p"${BOOKSTACK_DB_PASSWORD}" \
"${DB_DATABASE:-bookstack}" \ "${BOOKSTACK_DB_DATABASE:-bookstack}" \
| gzip > "$BACKUP_DIR/bookstack_db_$DATE.sql.gz" | gzip > "$BACKUP_DIR/bookstack_db_$DATE.sql.gz"
# 2. Backup BookStack uploads and config # 2. Backup BookStack uploads and config

BIN
data/.DS_Store vendored

Binary file not shown.

View file

@ -0,0 +1,177 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "49a243e8",
"metadata": {},
"source": [
"# Tello Drone Programming - Setup\n",
"\n",
"Dieses Notebook hilft dir, die Programmierung des DJI Tello Drohne einzurichten.\n",
"\n",
"## Schritt 1: Pakete installieren\n",
"\n",
"Führe die folgende Zelle aus, um die benötigten Python-Pakete zu installieren:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "4804023e",
"metadata": {},
"outputs": [],
"source": [
"# Installiere djitellopy und Abhängigkeiten\n",
"!pip install djitellopy opencv-python pillow"
]
},
{
"cell_type": "markdown",
"id": "a480f600",
"metadata": {},
"source": [
"## Schritt 2: Import und Verbindungstest\n",
"\n",
"Nach der Installation, importiere die Bibliothek:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "7bcda678",
"metadata": {},
"outputs": [],
"source": [
"from djitellopy import Tello\n",
"import time\n",
"\n",
"print(\"✅ djitellopy erfolgreich importiert!\")"
]
},
{
"cell_type": "markdown",
"id": "165d7797",
"metadata": {},
"source": [
"## Schritt 3: Drohne verbinden (nur ausführen wenn Drohne eingeschaltet ist!)\n",
"\n",
"**⚠️ WICHTIG:** Stelle sicher, dass:\n",
"1. Die Tello Drohne eingeschaltet ist\n",
"2. Dein Computer mit dem WiFi der Drohne verbunden ist (TELLO-XXXXXX)\n",
"3. Du genug Platz hast (mindestens 2x2 Meter freier Raum)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "6acedf15",
"metadata": {},
"outputs": [],
"source": [
"# Drohne initialisieren\n",
"drone = Tello()\n",
"\n",
"# Verbindung aufbauen\n",
"drone.connect()\n",
"\n",
"# Batterie-Status prüfen\n",
"battery = drone.get_battery()\n",
"print(f\"🔋 Batterie: {battery}%\")\n",
"\n",
"if battery < 20:\n",
" print(\"⚠️ Warnung: Batterie niedrig! Bitte aufladen.\")\n",
"else:\n",
" print(\"✅ Drohne bereit!\")"
]
},
{
"cell_type": "markdown",
"id": "c4c90848",
"metadata": {},
"source": [
"## Schritt 4: Einfacher Testflug\n",
"\n",
"**⚠️ SICHERHEIT ZUERST:**\n",
"- Freier Raum um dich herum\n",
"- Keine Personen in der Nähe\n",
"- Drohne auf ebener Fläche\n",
"- Bereit, die Drohne zu fangen falls nötig"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "3ce22798",
"metadata": {},
"outputs": [],
"source": [
"# Starten und einfache Bewegung\n",
"drone.takeoff() # Abheben\n",
"time.sleep(3) # 3 Sekunden schweben\n",
"\n",
"drone.move_up(30) # 30cm nach oben\n",
"time.sleep(2)\n",
"\n",
"drone.rotate_clockwise(90) # 90° drehen\n",
"time.sleep(2)\n",
"\n",
"drone.land() # Landen\n",
"print(\"✅ Testflug abgeschlossen!\")"
]
},
{
"cell_type": "markdown",
"id": "3b616df2",
"metadata": {},
"source": [
"## Nützliche Befehle\n",
"\n",
"Hier sind einige grundlegende Befehle:\n",
"\n",
"```python\n",
"# Bewegungen\n",
"drone.takeoff() # Abheben\n",
"drone.land() # Landen\n",
"drone.move_up(x) # x cm nach oben\n",
"drone.move_down(x) # x cm nach unten\n",
"drone.move_forward(x) # x cm vorwärts\n",
"drone.move_back(x) # x cm rückwärts\n",
"drone.move_left(x) # x cm links\n",
"drone.move_right(x) # x cm rechts\n",
"drone.rotate_clockwise(x) # x° im Uhrzeigersinn\n",
"drone.rotate_counter_clockwise(x) # x° gegen Uhrzeigersinn\n",
"\n",
"# Informationen\n",
"drone.get_battery() # Batterie-Status\n",
"drone.get_height() # Aktuelle Höhe\n",
"drone.get_speed() # Geschwindigkeit\n",
"\n",
"# Notfall\n",
"drone.emergency() # NOTLANDUNG (sofort stoppen!)\n",
"```"
]
},
{
"cell_type": "markdown",
"id": "441ebe1d",
"metadata": {},
"source": [
"## Weiterführende Ressourcen\n",
"\n",
"- [DJITelloPy Dokumentation](https://djitellopy.readthedocs.io/)\n",
"- [Beispiele auf GitHub](https://github.com/damiafuentes/DJITelloPy/tree/master/examples)\n",
"\n",
"---\n",
"\n",
"**Viel Spaß beim Programmieren! 🚁**"
]
}
],
"metadata": {
"language_info": {
"name": "python"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View file

@ -108,10 +108,10 @@ services:
image: lscr.io/linuxserver/bookstack:latest image: lscr.io/linuxserver/bookstack:latest
container_name: bookstack container_name: bookstack
environment: environment:
- APP_KEY=${BOOKSTACK_APP_KEY} - MYSQL_ROOT_PASSWORD=${BOOKSTACK_DB_PASSWORD}
- APP_URL=${BOOKSTACK_APP_URL} - MYSQL_DATABASE=${BOOKSTACK_DB_DATABASE}
env_file: - MYSQL_USER=${BOOKSTACK_DB_USERNAME}
- .env - MYSQL_PASSWORD=${BOOKSTACK_DB_PASSWORD}
volumes: volumes:
- ./bookstack/bookstack_app_data:/config - ./bookstack/bookstack_app_data:/config
ports: ports:
@ -121,7 +121,11 @@ services:
bookstack-mariadb: bookstack-mariadb:
condition: service_healthy condition: service_healthy
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost/ || exit 1"] test:
[
"CMD-SHELL",
"mariadb -u${BOOKSTACK_DB_USERNAME} -p${BOOKSTACK_DB_PASSWORD} -e 'SELECT 1' || exit 1",
]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@ -135,10 +139,10 @@ services:
image: lscr.io/linuxserver/mariadb:latest image: lscr.io/linuxserver/mariadb:latest
container_name: bookstack-mariadb container_name: bookstack-mariadb
environment: environment:
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD} - MYSQL_ROOT_PASSWORD=${BOOKSTACK_DB_PASSWORD}
- MYSQL_DATABASE=${DB_DATABASE} - MYSQL_DATABASE=${BOOKSTACK_DB_DATABASE}
- MYSQL_USER=${DB_USERNAME} - MYSQL_USER=${BOOKSTACK_DB_USERNAME}
- MYSQL_PASSWORD=${DB_PASSWORD} - MYSQL_PASSWORD=${BOOKSTACK_DB_PASSWORD}
- PUID=1000 - PUID=1000
- PGID=1000 - PGID=1000
- TZ=Europe/Berlin - TZ=Europe/Berlin
@ -151,7 +155,7 @@ services:
test: test:
[ [
"CMD-SHELL", "CMD-SHELL",
"mariadb -u${DB_USERNAME} -p${DB_PASSWORD} -e 'SELECT 1' || exit 1", "mariadb -u${BOOKSTACK_DB_USERNAME} -p${BOOKSTACK_DB_PASSWORD} -e 'SELECT 1' || exit 1",
] ]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
@ -207,6 +211,59 @@ services:
- "description=Portainer Container Management UI" - "description=Portainer Container Management UI"
- "maintainer=Studio EinsZwoVier" - "maintainer=Studio EinsZwoVier"
jupyterhub:
build: ./jupyterhub
container_name: jupyterhub
ports:
- "8001:8001"
- "8081:8081"
volumes:
- ./data:/home
- ./jupyterhub/jupyterhub_config.py:/srv/jupyterhub/jupyterhub_config.py:ro
- ./static/images/logo.png:/srv/jupyterhub/logo.png:ro
- /var/run/docker.sock:/var/run/docker.sock # Docker socket for spawning containers
env_file:
- .env
environment:
- JUPYTERHUB_SINGLEUSER_APP=jupyterlab
restart: unless-stopped
mem_limit: 2g
cpus: 1.0
depends_on:
- web
labels:
- "com.centurylinklabs.watchtower.enable=true"
- "description=JupyterHub for interactive notebooks"
- "maintainer=Studio EinsZwoVier"
forgejo:
image: codeberg.org/forgejo/forgejo:11
container_name: forgejo
environment:
- USER_UID=1000
- USER_GID=1000
- TZ=Europe/Berlin
- FORGEJO__repository__ENABLE_PUSH_CREATE_USER=true
restart: unless-stopped
ports:
- "${FORGEJO_PORT:-3003}:3000"
- "222:22"
volumes:
- ./forgejo/data:/data
mem_limit: 2g
cpus: 2.0
mem_reservation: 512m
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
labels:
- "com.centurylinklabs.watchtower.enable=true"
- "description=Forgejo Git Server"
- "maintainer=Studio EinsZwoVier"
volumes: volumes:
portainer_data: portainer_data:
driver: local driver: local

View file

@ -0,0 +1 @@
GITEA_CUSTOM=/data/gitea

View file

@ -0,0 +1,63 @@
APP_NAME = Forgejo: Beyond coding. We forge.
RUN_MODE = prod
[repository]
ROOT = /data/git/repositories
ENABLE_PUSH_CREATE_USER = true
[repository.local]
LOCAL_COPY_PATH = /data/gitea/tmp/local-repo
[repository.upload]
TEMP_PATH = /data/gitea/uploads
[server]
APP_DATA_PATH = /data/gitea
DOMAIN = localhost
SSH_DOMAIN = localhost
HTTP_PORT = 3000
ROOT_URL =
DISABLE_SSH = false
SSH_PORT = 22
SSH_LISTEN_PORT = 22
LFS_START_SERVER = false
[database]
PATH = /data/gitea/gitea.db
DB_TYPE = sqlite3
HOST = localhost:3306
NAME = gitea
USER = root
PASSWD =
LOG_SQL = false
[indexer]
ISSUE_INDEXER_PATH = /data/gitea/indexers/issues.bleve
[session]
PROVIDER_CONFIG = /data/gitea/sessions
[picture]
AVATAR_UPLOAD_PATH = /data/gitea/avatars
REPOSITORY_AVATAR_UPLOAD_PATH = /data/gitea/repo-avatars
[attachment]
PATH = /data/gitea/attachments
[log]
MODE = console
LEVEL = info
ROOT_PATH = /data/gitea/log
[security]
INSTALL_LOCK = false
SECRET_KEY =
REVERSE_PROXY_LIMIT = 1
REVERSE_PROXY_TRUSTED_PROXIES = *
[service]
DISABLE_REGISTRATION = false
REQUIRE_SIGNIN_VIEW = false
[lfs]
PATH = /data/git/lfs

View file

@ -0,0 +1,9 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS
1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQQ02EGaaw86ZKkpWSL8gktKug0A/XNF
TgcY469G/5ecX9H/AMxrPlOKCU0K+3eWGSCISQGlVLBRNpHK5VM9ga13AAAAsJrl+aya5f
msAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBDTYQZprDzpkqSlZ
IvyCS0q6DQD9c0VOBxjjr0b/l5xf0f8AzGs+U4oJTQr7d5YZIIhJAaVUsFE2kcrlUz2BrX
cAAAAhAPrnsLBtojpOaaAt0yDgy/lCpqRnfbGBkV5FMiFYvm+fAAAAEXJvb3RAMmQ4Yjk4
Nzg0OWMyAQIDBAUG
-----END OPENSSH PRIVATE KEY-----

View file

@ -0,0 +1 @@
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBDTYQZprDzpkqSlZIvyCS0q6DQD9c0VOBxjjr0b/l5xf0f8AzGs+U4oJTQr7d5YZIIhJAaVUsFE2kcrlUz2BrXc= root@2d8b987849c2

View file

@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACD7RYGr6qRspA4rgtvxhXJiS4a7z/hXm8q2XS9J8iqncQAAAJit3hcbrd4X
GwAAAAtzc2gtZWQyNTUxOQAAACD7RYGr6qRspA4rgtvxhXJiS4a7z/hXm8q2XS9J8iqncQ
AAAEB2I1zsRzNSHt6lqkavsXdj1fi2cDcnSCImC4Kpaqi3APtFgavqpGykDiuC2/GFcmJL
hrvP+FebyrZdL0nyKqdxAAAAEXJvb3RAMmQ4Yjk4Nzg0OWMyAQIDBA==
-----END OPENSSH PRIVATE KEY-----

View file

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPtFgavqpGykDiuC2/GFcmJLhrvP+FebyrZdL0nyKqdx root@2d8b987849c2

View file

@ -0,0 +1,38 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEAthX4qCOdjmumy+PNMf2RUIiakuBdqQXMRIfzpKMxsjSMuxf06Ysq
YT+fRR6EOdibUzMHJl9BqA/Gzw2ItuhSG9roKVvkwbYoyTfpt6tN6qxJysTB6zLGUKGU/m
ztOVgFL5aTtqjvou3+UEn0BIvoXl6xu1gtjGJYt2Qt/uTxKSt3svcG4XO556E7R2VtU7v9
nJ6rtgJCx55Iq50zRgxzO92nchEnrfsKbOrG8q+6jv4njpsBqEiHpXNipP8wUHcwADL6Wx
PBcs6yuanCWgwQ8eYKyyQk/roLcSD7V0YXBH+CxkP9P9Z7QXdfaiA+g2ou8lEuOwtZsRNi
Fk5G9l7trZOg9E9ZIfb4YyFVA99akKLQM4vpB6bh8+7Si3yMUY7w9mFI4svd8oJxTsaH01
t0TtYq2ziy2HO1jwwaeRHtyD2VA7t+t3IlYrhdMNQSTv791ZHyKXxcCVv3Zt0gIXlvFLTn
+XsaWt90wkst7UihDUHYaeH4CkV1c7+n1d8UD++bAAAFiG44F9ZuOBfWAAAAB3NzaC1yc2
EAAAGBALYV+KgjnY5rpsvjzTH9kVCImpLgXakFzESH86SjMbI0jLsX9OmLKmE/n0UehDnY
m1MzByZfQagPxs8NiLboUhva6Clb5MG2KMk36berTeqsScrEwesyxlChlP5s7TlYBS+Wk7
ao76Lt/lBJ9ASL6F5esbtYLYxiWLdkLf7k8Skrd7L3BuFzueehO0dlbVO7/Zyeq7YCQsee
SKudM0YMczvdp3IRJ637CmzqxvKvuo7+J46bAahIh6VzYqT/MFB3MAAy+lsTwXLOsrmpwl
oMEPHmCsskJP66C3Eg+1dGFwR/gsZD/T/We0F3X2ogPoNqLvJRLjsLWbETYhZORvZe7a2T
oPRPWSH2+GMhVQPfWpCi0DOL6Qem4fPu0ot8jFGO8PZhSOLL3fKCcU7Gh9NbdE7WKts4st
hztY8MGnkR7cg9lQO7frdyJWK4XTDUEk7+/dWR8il8XAlb92bdICF5bxS05/l7GlrfdMJL
Le1IoQ1B2Gnh+ApFdXO/p9XfFA/vmwAAAAMBAAEAAAGAECkOCxoyGxhJ0vGyXfvzwDKHiX
6ZQW2OzgxE3vlO6VKJpPdA2NNtnPjxEUjekmW7j1xJh6nPoXNZATphxl4DH47DqRwLRvf8
UbOBLjhpb2kAGZtx3IaCnFhi6VvQiBTcTPdvv7fpoMu/lO+jVR33rxx3aLmwPTPjTM9615
MJJk7BzmPnO+4x8zFXmgQR+msGXLamZb54n8/YAkcu7EohlhAbkt+b5nCP4c/KfXKEO7mp
2BnAwWdChrghaqRtbM7PELl063dErTMOIV/Y6GIAwDAW2OYCmdNtxyDCnkeVZmc91gm8RF
rAw7wtB20PrexOouMr0efhVMoAoeSZsFd8XODFZyT0kogZITV7up3zxxpHuxWkA2whWTnx
Q6tAPt1MphPmnsGovjUo1nC/7Bbr8jr3JDyrml2wnosl7IrikLDGvlPnBlVozkOYHHSNj9
SxQ8hKVp1pobJnSY68RugD3lcu9Lo9bCT1jZUdoqz+7/rxhbjXfsdlJpkYLDy7hYWRAAAA
wGncwCzLEdVHz0IXtYCW9MVgYapqBG+GeOUQpz20hZKWatSlH7NunvtoEs0UG1uX7fGEhD
61hl1OblOBkAlVI85uHposs1cLex+hpLVqDBRu+DesQz/jQIOL69CmUyPIgkdrouw7ygaO
XBpJiucVjWv3305+23lLNFvOKffPGmAv1lw1+qtULxu0qFrvwuUEGMy/J7zxoA/z1ePWXl
VEka1WRZkW4OC4dDDZkKa6j0+Z14CutYAu98gxyqmaAUIwZQAAAMEA8j6HMkIbPW6aAYNF
lBHPQtXpud92Y0+Xs5/tOiSMWL4iaBSTcKhVyWiLaGYdd1JwGmgDJY4i7T8stwPJSEUtP/
7hiHH/dGITP+DZ9Eqrs6a3uhhBO0KnM3YyJkK9zFOnSx8v9dy7XNJqFbPT7jETFQ0CciFc
mxbicCK2T6wJjqr+QXWAw/KU9uuVxN3vn6EeBvmX+qdbwQjKWScyBaPj0igtAQ87B1wYzl
UmqXTTNBn8HcVEfldw8VO9toXwSkIrAAAAwQDAbO2md4yHS3vtVzgU1I3GACxKjp+bIOFZ
97l/YWsSA0DBJQju7z83ibM2eOtrHeeXMtYi0PANEsxL5KRTZ5nmodRD3QK7QxcfZVJ0Z+
0/s+ZqeJpC/95aex0ZvkntUdf9UVbM7LFCAG/9puvuMe0oUQ2ChEwjHj0/RXH+zVay200C
z8fC2FqKuSehoLS/EWuBJv4AoS/xh7/4QOK0BG1s+cfkXW35/xefEziiZ6jsMkqboIgqsD
+09ZivL5ozAFEAAAARcm9vdEAyZDhiOTg3ODQ5YzIBAg==
-----END OPENSSH PRIVATE KEY-----

View file

@ -0,0 +1 @@
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC2FfioI52Oa6bL480x/ZFQiJqS4F2pBcxEh/OkozGyNIy7F/TpiyphP59FHoQ52JtTMwcmX0GoD8bPDYi26FIb2ugpW+TBtijJN+m3q03qrEnKxMHrMsZQoZT+bO05WAUvlpO2qO+i7f5QSfQEi+heXrG7WC2MYli3ZC3+5PEpK3ey9wbhc7nnoTtHZW1Tu/2cnqu2AkLHnkirnTNGDHM73adyESet+wps6sbyr7qO/ieOmwGoSIelc2Kk/zBQdzAAMvpbE8FyzrK5qcJaDBDx5grLJCT+ugtxIPtXRhcEf4LGQ/0/1ntBd19qID6Dai7yUS47C1mxE2IWTkb2Xu2tk6D0T1kh9vhjIVUD31qQotAzi+kHpuHz7tKLfIxRjvD2YUjiy93ygnFOxofTW3RO1irbOLLYc7WPDBp5Ee3IPZUDu363ciViuF0w1BJO/v3VkfIpfFwJW/dm3SAheW8UtOf5expa33TCSy3tSKENQdhp4fgKRXVzv6fV3xQP75s= root@2d8b987849c2

28
jupyterhub/Dockerfile Normal file
View file

@ -0,0 +1,28 @@
FROM python:3.13-slim
# Install system deps
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
nodejs npm \
git \
sudo \
&& rm -rf /var/lib/apt/lists/*
# Install JupyterHub, JupyterLab, notebook (required for singleuser), and DockerSpawner
RUN pip install --no-cache-dir jupyterhub jupyterlab notebook dockerspawner
# Install configurable-http-proxy (required by JupyterHub)
RUN npm install -g configurable-http-proxy@^4.0.0
# Ensure npm global binaries are available in PATH
ENV PATH="/usr/local/bin:${PATH}"
# Create directories
RUN mkdir -p /srv/jupyterhub /srv/jupyterhub/data
WORKDIR /srv/jupyterhub
COPY jupyterhub_config.py /srv/jupyterhub/
EXPOSE 8001
CMD ["jupyterhub", "-f", "/srv/jupyterhub/jupyterhub_config.py"]

View file

@ -0,0 +1,79 @@
from traitlets.config import get_config
import os
# Minimal JupyterHub config for local multi-user usage inside docker-compose.
# This config is intentionally small — customize for auth, spawners, and TLS in production.
c = get_config()
# Hub internal URL (where the hub process listens for /hub requests)
c.JupyterHub.bind_url = 'http://:8081/hub/'
# Hub IP for spawned containers to connect back (use container name on Docker network)
c.JupyterHub.hub_connect_ip = 'jupyterhub'
# Proxy configuration
# - Proxy public address (what users access): default is port 8000, we want 8001
# - Proxy API address (hub talks to proxy control): separate port 8002
c.JupyterHub.port = 8001 # Public proxy port
c.ConfigurableHTTPProxy.api_url = 'http://127.0.0.1:8002'
# Lightweight built-in authenticator: no external package required. This is
# intended only for local development/testing. It accepts any username as long
# as the password equals the value of JUPYTER_PASSWORD (default: einszwo4).
from jupyterhub.auth import Authenticator
class SimpleEnvPasswordAuthenticator(Authenticator):
async def authenticate(self, handler, data):
"""Authenticate any username when the shared env password matches.
Dev-only: returns the username on success, otherwise None.
"""
username = data.get('username')
password = data.get('password')
if not username or not password:
return None
if password == os.environ.get('JUPYTER_PASSWORD', 'einszwo4'):
return username
return None
# Use the in-file authenticator for dev/testing
c.JupyterHub.authenticator_class = SimpleEnvPasswordAuthenticator
# Allow any authenticated user to access (suppress the warning)
c.Authenticator.allow_all = True
# Use DockerSpawner for production-ready containerized user servers
from dockerspawner import DockerSpawner
c.JupyterHub.spawner_class = DockerSpawner
# Docker image for single-user notebook servers
c.DockerSpawner.image = 'jupyter/scipy-notebook:latest'
# Connect to Docker socket
c.DockerSpawner.use_internal_ip = True
c.DockerSpawner.network_name = 'einszwovier_network'
# Remove containers after they stop
c.DockerSpawner.remove = True
# Mount the data directory for persistent storage
# Use the data directory we already have mounted in docker-compose
c.DockerSpawner.volumes = {
'jupyterhub-user-{username}': {'bind': '/home/jovyan/work', 'mode': 'rw'}
}
# Increase spawn timeout
c.Spawner.start_timeout = 120
c.Spawner.http_timeout = 120
# Set notebook directory and default interface
c.Spawner.notebook_dir = '/home/jovyan/work'
c.Spawner.default_url = '/lab'
# UI and data locations
c.JupyterHub.logo_file = '/srv/jupyterhub/logo.png'
c.JupyterHub.cookie_secret_file = '/srv/jupyterhub/jupyterhub_cookie_secret'
# Note: DockerSpawner creates isolated containers for each user.
# Each user gets their own containerized Jupyter environment with persistent storage.

View file

@ -18,6 +18,7 @@ SERVER_HOSTNAME = os.environ.get("SERVER_HOSTNAME", "einszwovier.local")
BOOKSTACK_PORT = os.environ.get("BOOKSTACK_PORT", "6875") BOOKSTACK_PORT = os.environ.get("BOOKSTACK_PORT", "6875")
OPENWEBUI_PORT = os.environ.get("OPENWEBUI_PORT", "8080") OPENWEBUI_PORT = os.environ.get("OPENWEBUI_PORT", "8080")
PORTAINER_PORT = os.environ.get("PORTAINER_PORT", "9000") PORTAINER_PORT = os.environ.get("PORTAINER_PORT", "9000")
FORGEJO_PORT = os.environ.get("FORGEJO_PORT", "3003")
# Courses CSV path # Courses CSV path
COURSES_CSV = Path("data/courses.csv") COURSES_CSV = Path("data/courses.csv")
@ -84,6 +85,7 @@ async def welcome(request: Request):
"bookstack_port": BOOKSTACK_PORT, "bookstack_port": BOOKSTACK_PORT,
"openwebui_port": OPENWEBUI_PORT, "openwebui_port": OPENWEBUI_PORT,
"portainer_port": PORTAINER_PORT, "portainer_port": PORTAINER_PORT,
"forgejo_port": FORGEJO_PORT,
}, },
) )

View file

@ -34,9 +34,9 @@ sleep 10 # Wait for MariaDB to start
zcat "$BACKUP_DIR/bookstack_db_$DATE.sql.gz" | \ zcat "$BACKUP_DIR/bookstack_db_$DATE.sql.gz" | \
docker exec -i bookstack-mariadb mariadb \ docker exec -i bookstack-mariadb mariadb \
-u"${DB_USERNAME:-bookstack}" \ -u"${BOOKSTACK_DB_USERNAME:-bookstack}" \
-p"${DB_PASSWORD}" \ -p"${BOOKSTACK_DB_PASSWORD}" \
"${DB_DATABASE:-bookstack}" "${BOOKSTACK_DB_DATABASE:-bookstack}"
# 2. Restore BookStack files # 2. Restore BookStack files
echo "Restoring BookStack files..." echo "Restoring BookStack files..."

View file

@ -60,6 +60,18 @@
<div class="tagline">Teste unsere schulischen Large Language Models direkt über die Weboberfläche.</div> <div class="tagline">Teste unsere schulischen Large Language Models direkt über die Weboberfläche.</div>
</a> </a>
<a class="link-card" href="http://{{ server_hostname }}:8001" target="_blank">
<div class="title">JupyterHub</div>
<div class="tagline">Interaktive Python-Notebooks für Datenanalyse, Visualisierung und kollaboratives
Programmieren. Schreib uns eine E-Mail für Login-Daten.</div>
</a>
<a class="link-card" href="http://{{ server_hostname }}:{{ forgejo_port }}" target="_blank">
<div class="title">Forgejo Git Server</div>
<div class="tagline">Versionskontrolle und Code-Hosting für kollaborative Softwareprojekte. Ideal für gemeinsame
Entwicklung und Projektmanagement.</div>
</a>
<a class="link-card" href="mailto:einszwovier@gvb-gymnasium.de" target="_blank"> <a class="link-card" href="mailto:einszwovier@gvb-gymnasium.de" target="_blank">
<div class="title">Kontakt</div> <div class="title">Kontakt</div>
<div class="tagline">Schreibe uns direkt an: einszwovier@gvb-gymnasium.de</div> <div class="tagline">Schreibe uns direkt an: einszwovier@gvb-gymnasium.de</div>
@ -70,7 +82,8 @@
<!-- Footer with Admin Panel and Source Link --> <!-- Footer with Admin Panel and Source Link -->
<footer class="footer"> <footer class="footer">
<div class="footer-container"> <div class="footer-container">
<a href="http://{{ server_hostname }}:{{ portainer_port }}" target="_blank" class="admin-link">Admin Panel (Portainer)</a> <a href="http://{{ server_hostname }}:{{ portainer_port }}" target="_blank" class="admin-link">Admin Panel
(Portainer)</a>
<span class="footer-source"> <span class="footer-source">
| <a href="https://forgejo.petau.net/aron/124-webapp" target="_blank">Quellcode der Website</a> | <a href="https://forgejo.petau.net/aron/124-webapp" target="_blank">Quellcode der Website</a>
</span> </span>