Edit files
This commit is contained in:
@@ -8,9 +8,6 @@ NEXTAUTH_URL=http://localhost:3000
|
|||||||
# Production (change to your domain)
|
# Production (change to your domain)
|
||||||
# NEXTAUTH_URL=https://your-domain.com
|
# NEXTAUTH_URL=https://your-domain.com
|
||||||
|
|
||||||
# n8n API (optional - currently using client-side fetch)
|
|
||||||
N8N_API_URL=https://n8n.example.com/webhook/location
|
|
||||||
|
|
||||||
# SMTP Configuration (Fallback when DB config is empty)
|
# SMTP Configuration (Fallback when DB config is empty)
|
||||||
SMTP_HOST=smtp.gmail.com
|
SMTP_HOST=smtp.gmail.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
|
|||||||
108
GEMINI.md
Normal file
108
GEMINI.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Gemini Code Assistant Context
|
||||||
|
|
||||||
|
This document provides a comprehensive overview of the **Location Tracker** project, designed to give the Gemini code assistant the necessary context to understand the codebase, its architecture, and its conventions.
|
||||||
|
|
||||||
|
## 1. Project Overview
|
||||||
|
|
||||||
|
This is a full-stack web application for real-time location tracking, built with Next.js 14. Its primary purpose is to receive location data from [OwnTracks](https://owntracks.org/) mobile clients via an MQTT broker and display the tracks on an interactive map.
|
||||||
|
|
||||||
|
### Key Technologies
|
||||||
|
|
||||||
|
- **Framework**: Next.js 14 (App Router)
|
||||||
|
- **Language**: TypeScript
|
||||||
|
- **Database**: Dual SQLite databases using `better-sqlite3`.
|
||||||
|
- `data/database.sqlite`: For core application data like users, devices, and settings.
|
||||||
|
- `data/locations.sqlite`: For high-volume, time-series location data.
|
||||||
|
- **Data Ingestion**: An MQTT subscriber (`lib/mqtt-subscriber.ts`) connects to a Mosquitto broker to receive location updates from OwnTracks clients. An HTTP endpoint (`/api/locations/ingest`) is also available for manual data pushes.
|
||||||
|
- **Authentication**: `next-auth` (v5) with a credentials (username/password) provider. It supports `ADMIN` and `VIEWER` roles.
|
||||||
|
- **Frontend**: React, [React Leaflet](https://react-leaflet.js.org/) for mapping, and Tailwind CSS for styling.
|
||||||
|
- **Deployment**: The project includes a `Dockerfile` and `docker-compose.yml` for containerized deployment alongside a Mosquitto MQTT broker.
|
||||||
|
|
||||||
|
### Core Features
|
||||||
|
|
||||||
|
- Interactive map displaying real-time location data.
|
||||||
|
- Filtering by device and time range.
|
||||||
|
- Admin dashboard for user and device management.
|
||||||
|
- Secure authentication with role-based access control.
|
||||||
|
- Database maintenance tasks (cleanup, optimization) via API or scripts.
|
||||||
|
|
||||||
|
## 2. Building and Running the Project
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- npm
|
||||||
|
|
||||||
|
### Development Mode
|
||||||
|
|
||||||
|
1. **Install dependencies:**
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Initialize the databases:**
|
||||||
|
This command creates the two SQLite databases in the `data/` directory and seeds them with a default admin user (`admin` / `admin123`).
|
||||||
|
```bash
|
||||||
|
npm run db:init
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Run the development server:**
|
||||||
|
This starts the Next.js application on `http://localhost:3000`. It also starts the MQTT subscriber service.
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Mode
|
||||||
|
|
||||||
|
- **Build the application:**
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
- **Start the application:**
|
||||||
|
```bash
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
The most straightforward way to run the application and its dependent MQTT broker is using Docker Compose.
|
||||||
|
|
||||||
|
1. **Create an environment file:**
|
||||||
|
Copy `.env.example` to `.env` and fill in the required secrets, especially `AUTH_SECRET`.
|
||||||
|
|
||||||
|
2. **Start the services:**
|
||||||
|
```bash
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
This will build the Next.js app image and start both the application container and the Mosquitto container. The `data` directory is mounted as a volume to persist the SQLite databases.
|
||||||
|
|
||||||
|
## 3. Architecture and Data Flow
|
||||||
|
|
||||||
|
The application follows a clean, decoupled architecture.
|
||||||
|
|
||||||
|
**Data Ingestion Flow:**
|
||||||
|
1. An OwnTracks client publishes a location update to a specific MQTT topic (e.g., `owntracks/user/device10`).
|
||||||
|
2. The Mosquitto broker receives the message.
|
||||||
|
3. The Next.js application's MQTT Subscriber (`lib/mqtt-subscriber.ts`), which is started on server boot via `instrumentation.ts`, is subscribed to the `owntracks/+/+` topic.
|
||||||
|
4. Upon receiving a message, the subscriber parses the payload, transforms it into the application's `Location` format, and saves it to `data/locations.sqlite` using the functions in `lib/db.ts`.
|
||||||
|
|
||||||
|
**Data Retrieval Flow:**
|
||||||
|
1. The user's browser, viewing the map page, periodically fetches data from the `/api/locations` endpoint.
|
||||||
|
2. This API route queries the `data/locations.sqlite` database, applying any user-specified filters for device or time.
|
||||||
|
3. The API returns the location data as a JSON response.
|
||||||
|
4. The frontend `MapView.tsx` component renders the received data as markers and polylines on the Leaflet map.
|
||||||
|
|
||||||
|
**Authentication & Authorization:**
|
||||||
|
- Users log in via the `/login` page, which uses the NextAuth.js `Credentials` provider.
|
||||||
|
- The `lib/auth.ts` file contains the NextAuth configuration, validating credentials against the `User` table in `data/database.sqlite`.
|
||||||
|
- The `middleware.ts` file protects the `/admin` and `/map` routes, checking for a valid session and enforcing role-based access (`ADMIN` vs. `VIEWER`).
|
||||||
|
|
||||||
|
## 4. Development Conventions
|
||||||
|
|
||||||
|
- **Database Access**: All direct database operations are encapsulated within the `lib/db.ts` file. This provides a single, consistent interface (`userDb`, `deviceDb`, `locationDb`) for interacting with the data layer.
|
||||||
|
- **Authentication**: All authentication and session logic is centralized in `lib/auth.ts`. The `auth()` object exported from this file is the primary entry point for accessing session data on the server.
|
||||||
|
- **Background Services**: Long-running background services, like the MQTT subscriber, should be initialized in `lib/startup.ts` and triggered from the `instrumentation.ts` file. This is the standard Next.js way to run code on server startup.
|
||||||
|
- **Configuration**: Environment variables are used for configuration (see `.env.example`).
|
||||||
|
- **Scripts**: The `scripts/` directory contains Node.js scripts for various administrative tasks, such as database initialization (`init-database.js`), cleanup (`cleanup-old-locations.js`), and testing. They should be run using `node scripts/<script_name>.js`.
|
||||||
|
- **Styling**: Utility-first CSS using Tailwind CSS.
|
||||||
|
- **Types**: TypeScript types are used throughout. Core application types are defined in files like `lib/db.ts` and `types/location.ts`.
|
||||||
@@ -1,559 +0,0 @@
|
|||||||
# MQTT Provisioning Integration
|
|
||||||
|
|
||||||
Diese Anleitung beschreibt die Integration des MQTT Provisioning Systems (aus `mosquitto-automation`) in die Location Tracker App.
|
|
||||||
|
|
||||||
## 🎯 Übersicht
|
|
||||||
|
|
||||||
Die Integration vereint zwei vormals separate Systeme:
|
|
||||||
- **mosquitto-automation**: Device Provisioning und MQTT Credential Management
|
|
||||||
- **location-tracker-app**: GPS Tracking Visualisierung mit OwnTracks
|
|
||||||
|
|
||||||
### Was wurde integriert?
|
|
||||||
|
|
||||||
✅ **MQTT Credentials Management** - Direkt im Admin Panel
|
|
||||||
✅ **ACL (Access Control List) Management** - Feine Kontrolle über Topic-Berechtigungen
|
|
||||||
✅ **Mosquitto Sync** - Password & ACL Files werden automatisch generiert
|
|
||||||
✅ **MQTT Subscriber** - Direkte Verarbeitung von OwnTracks Messages (kein n8n mehr nötig)
|
|
||||||
✅ **Docker Compose Setup** - All-in-One Deployment
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Features
|
|
||||||
|
|
||||||
### Admin Panel: MQTT Provisioning
|
|
||||||
|
|
||||||
**Route:** `/admin/mqtt`
|
|
||||||
|
|
||||||
#### Device Provisioning
|
|
||||||
- Erstelle MQTT Credentials für registrierte Devices
|
|
||||||
- Automatische Generierung von Username & Passwort
|
|
||||||
- Passwörter werden mit `mosquitto_passwd` gehasht
|
|
||||||
- Default ACL Regel: `owntracks/[device-id]/#` (readwrite)
|
|
||||||
|
|
||||||
#### Credentials Management
|
|
||||||
- Liste aller provisionierten Devices
|
|
||||||
- Enable/Disable MQTT Zugriff pro Device
|
|
||||||
- Passwort Regenerierung
|
|
||||||
- Credentials löschen (inkl. ACL Regeln)
|
|
||||||
|
|
||||||
#### ACL Management
|
|
||||||
- Custom Topic Patterns definieren
|
|
||||||
- Berechtigungen: `read`, `write`, `readwrite`
|
|
||||||
- Wildcard Support mit `#`
|
|
||||||
- Regeln pro Device verwalten
|
|
||||||
|
|
||||||
#### Mosquitto Sync
|
|
||||||
- **"Zu Mosquitto Syncen"** Button im Admin Panel
|
|
||||||
- Generiert `/mosquitto/config/password.txt`
|
|
||||||
- Generiert `/mosquitto/config/acl.txt`
|
|
||||||
- Sendet SIGHUP an Mosquitto Container (Config Reload)
|
|
||||||
- Zeigt ausstehende Änderungen an
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🗄️ Datenbankschema
|
|
||||||
|
|
||||||
### Neue Tabellen
|
|
||||||
|
|
||||||
#### `mqtt_credentials`
|
|
||||||
```sql
|
|
||||||
CREATE TABLE mqtt_credentials (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
device_id TEXT NOT NULL UNIQUE, -- Referenz zu Device Tabelle
|
|
||||||
mqtt_username TEXT NOT NULL UNIQUE,
|
|
||||||
mqtt_password_hash TEXT NOT NULL, -- Mosquitto-kompatible Hash
|
|
||||||
enabled INTEGER DEFAULT 1, -- 0 = disabled, 1 = enabled
|
|
||||||
created_at TEXT,
|
|
||||||
updated_at TEXT,
|
|
||||||
FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `mqtt_acl_rules`
|
|
||||||
```sql
|
|
||||||
CREATE TABLE mqtt_acl_rules (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
device_id TEXT NOT NULL,
|
|
||||||
topic_pattern TEXT NOT NULL, -- z.B. "owntracks/device10/#"
|
|
||||||
permission TEXT NOT NULL, -- read | write | readwrite
|
|
||||||
created_at TEXT,
|
|
||||||
FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `mqtt_sync_status`
|
|
||||||
```sql
|
|
||||||
CREATE TABLE mqtt_sync_status (
|
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1), -- Singleton
|
|
||||||
pending_changes INTEGER DEFAULT 0,
|
|
||||||
last_sync_at TEXT,
|
|
||||||
last_sync_status TEXT, -- success | error: ...
|
|
||||||
created_at TEXT,
|
|
||||||
updated_at TEXT
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Migration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Datenbanken initialisieren
|
|
||||||
npm run db:init
|
|
||||||
|
|
||||||
# MQTT Tabellen hinzufügen
|
|
||||||
node scripts/add-mqtt-tables.js
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Installation & Setup
|
|
||||||
|
|
||||||
### Voraussetzungen
|
|
||||||
|
|
||||||
- Docker & Docker Compose
|
|
||||||
- Node.js 20+ (für lokale Entwicklung)
|
|
||||||
|
|
||||||
### 1. Repository Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd location-tracker-app
|
|
||||||
|
|
||||||
# Dependencies installieren
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# .env Datei erstellen
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Environment Variables
|
|
||||||
|
|
||||||
Bearbeite `.env`:
|
|
||||||
|
|
||||||
```env
|
|
||||||
# MQTT Configuration
|
|
||||||
MQTT_BROKER_URL=mqtt://mosquitto:1883
|
|
||||||
MQTT_ADMIN_USERNAME=admin
|
|
||||||
MQTT_ADMIN_PASSWORD=dein-sicheres-passwort
|
|
||||||
|
|
||||||
MOSQUITTO_PASSWORD_FILE=/mosquitto/config/password.txt
|
|
||||||
MOSQUITTO_ACL_FILE=/mosquitto/config/acl.txt
|
|
||||||
MOSQUITTO_CONTAINER_NAME=mosquitto
|
|
||||||
|
|
||||||
# NextAuth
|
|
||||||
NEXTAUTH_URL=http://localhost:3000
|
|
||||||
NEXTAUTH_SECRET=<generiere mit: openssl rand -base64 32>
|
|
||||||
|
|
||||||
# Verschlüsselung für SMTP Passwords
|
|
||||||
ENCRYPTION_KEY=<generiere mit: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))">
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Docker Compose Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build und Start
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# Logs verfolgen
|
|
||||||
docker-compose logs -f
|
|
||||||
|
|
||||||
# Status prüfen
|
|
||||||
docker-compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
Die App läuft auf: `http://localhost:3000`
|
|
||||||
Mosquitto MQTT Broker: `mqtt://localhost:1883`
|
|
||||||
|
|
||||||
### 4. Admin Zugang
|
|
||||||
|
|
||||||
**Default Credentials:**
|
|
||||||
- Username: `admin`
|
|
||||||
- Password: `admin123`
|
|
||||||
|
|
||||||
⚠️ **Ändere das Passwort nach dem ersten Login!**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Entwicklung
|
|
||||||
|
|
||||||
### Lokale Entwicklung (ohne Docker)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Mosquitto extern starten (oder Docker Compose nur Mosquitto)
|
|
||||||
docker run -d -p 1883:1883 -p 9001:9001 \
|
|
||||||
-v $(pwd)/mosquitto.conf:/mosquitto/config/mosquitto.conf \
|
|
||||||
-v mosquitto_data:/mosquitto/data \
|
|
||||||
eclipse-mosquitto:2
|
|
||||||
|
|
||||||
# 2. .env anpassen
|
|
||||||
MQTT_BROKER_URL=mqtt://localhost:1883
|
|
||||||
|
|
||||||
# 3. Datenbanken initialisieren
|
|
||||||
npm run db:init
|
|
||||||
node scripts/add-mqtt-tables.js
|
|
||||||
|
|
||||||
# 4. App starten
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Neue MQTT Credentials testen
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Mit mosquitto_sub testen
|
|
||||||
mosquitto_sub -h localhost -p 1883 \
|
|
||||||
-u "device_10_abc123" \
|
|
||||||
-P "dein-generiertes-passwort" \
|
|
||||||
-t "owntracks/10/#" \
|
|
||||||
-v
|
|
||||||
|
|
||||||
# OwnTracks Message simulieren
|
|
||||||
mosquitto_pub -h localhost -p 1883 \
|
|
||||||
-u "device_10_abc123" \
|
|
||||||
-P "dein-generiertes-passwort" \
|
|
||||||
-t "owntracks/10/device" \
|
|
||||||
-m '{"_type":"location","lat":52.5200,"lon":13.4050,"tst":1234567890,"batt":85,"vel":5.2}'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📡 MQTT Subscriber
|
|
||||||
|
|
||||||
Der MQTT Subscriber läuft automatisch beim App-Start und verarbeitet OwnTracks Messages.
|
|
||||||
|
|
||||||
### Implementierung
|
|
||||||
|
|
||||||
- **Service:** `lib/mqtt-subscriber.ts`
|
|
||||||
- **Startup:** `instrumentation.ts` (Next.js Hook)
|
|
||||||
- **Topic:** `owntracks/+/+`
|
|
||||||
- **Datenbank:** Schreibt direkt in `locations.sqlite`
|
|
||||||
|
|
||||||
### OwnTracks Message Format
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"_type": "location",
|
|
||||||
"tid": "XX",
|
|
||||||
"lat": 52.5200,
|
|
||||||
"lon": 13.4050,
|
|
||||||
"tst": 1234567890,
|
|
||||||
"batt": 85,
|
|
||||||
"vel": 5.2,
|
|
||||||
"acc": 10,
|
|
||||||
"alt": 50
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logs
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Docker Logs
|
|
||||||
docker-compose logs -f app
|
|
||||||
|
|
||||||
# Du solltest sehen:
|
|
||||||
# ✓ Connected to MQTT broker
|
|
||||||
# ✓ Subscribed to owntracks/+/+
|
|
||||||
# ✓ Location saved: device10 at (52.52, 13.405)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔐 Sicherheit
|
|
||||||
|
|
||||||
### Mosquitto Authentication
|
|
||||||
|
|
||||||
- **Keine Anonymous Connections:** `allow_anonymous false`
|
|
||||||
- **Password File:** Mosquitto-kompatible Hashes (SHA512)
|
|
||||||
- **ACL File:** Topic-basierte Access Control
|
|
||||||
|
|
||||||
### Best Practices
|
|
||||||
|
|
||||||
1. **Starke Admin Passwörter:** Ändere `MQTT_ADMIN_PASSWORD` in `.env`
|
|
||||||
2. **Device Passwörter:** Auto-generierte Passwörter haben 128 Bit Entropie
|
|
||||||
3. **ACL Regeln:** Gib Devices nur Zugriff auf ihre eigenen Topics
|
|
||||||
4. **Docker Socket:** Container benötigt Zugriff für Mosquitto Reload (optional)
|
|
||||||
|
|
||||||
### ACL Beispiele
|
|
||||||
|
|
||||||
```text
|
|
||||||
# Device darf nur in eigenes Topic schreiben
|
|
||||||
user device_10_abc123
|
|
||||||
topic readwrite owntracks/10/#
|
|
||||||
|
|
||||||
# Device mit zusätzlichem Read-only Topic
|
|
||||||
user device_11_xyz789
|
|
||||||
topic readwrite owntracks/11/#
|
|
||||||
topic read status/#
|
|
||||||
|
|
||||||
# Admin hat vollen Zugriff
|
|
||||||
user admin
|
|
||||||
topic readwrite #
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
|
||||||
|
|
||||||
### Problem: "Mosquitto configuration reloaded" fehlgeschlagen
|
|
||||||
|
|
||||||
**Symptom:** Nach Sync kommt Warnung "Could not reload Mosquitto automatically"
|
|
||||||
|
|
||||||
**Lösung:** Docker Socket Zugriff fehlt. Entweder:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Option 1: Manuelle Mosquitto Neustart
|
|
||||||
docker-compose restart mosquitto
|
|
||||||
|
|
||||||
# Option 2: Docker Socket in docker-compose.yml freigeben (bereits konfiguriert)
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
```
|
|
||||||
|
|
||||||
### Problem: MQTT Subscriber verbindet nicht
|
|
||||||
|
|
||||||
**Debug Steps:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Prüfe Mosquitto läuft
|
|
||||||
docker-compose ps mosquitto
|
|
||||||
|
|
||||||
# 2. Prüfe Mosquitto Logs
|
|
||||||
docker-compose logs mosquitto
|
|
||||||
|
|
||||||
# 3. Prüfe App Logs
|
|
||||||
docker-compose logs app | grep MQTT
|
|
||||||
|
|
||||||
# 4. Teste MQTT Verbindung manuell
|
|
||||||
mosquitto_sub -h localhost -p 1883 -u admin -P admin -t '#'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Problem: Password Hash falsch
|
|
||||||
|
|
||||||
**Symptom:** Authentication failed im Mosquitto Log
|
|
||||||
|
|
||||||
**Lösung:** `mosquitto_passwd` Tool muss im Container verfügbar sein (ist im Dockerfile installiert)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Im Container testen
|
|
||||||
docker exec -it location-tracker-app mosquitto_passwd -h
|
|
||||||
```
|
|
||||||
|
|
||||||
### Problem: ACL Regeln funktionieren nicht
|
|
||||||
|
|
||||||
**Debug:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# ACL File prüfen
|
|
||||||
docker exec -it location-tracker-app cat /mosquitto/config/acl.txt
|
|
||||||
|
|
||||||
# Mosquitto Logs auf "Access denied" prüfen
|
|
||||||
docker-compose logs mosquitto | grep -i denied
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 API Endpoints
|
|
||||||
|
|
||||||
### MQTT Credentials
|
|
||||||
|
|
||||||
```http
|
|
||||||
# Liste aller Credentials
|
|
||||||
GET /api/mqtt/credentials
|
|
||||||
|
|
||||||
# Credential für Device abrufen
|
|
||||||
GET /api/mqtt/credentials/{device_id}
|
|
||||||
|
|
||||||
# Neue Credentials erstellen
|
|
||||||
POST /api/mqtt/credentials
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"device_id": "10",
|
|
||||||
"auto_generate": true
|
|
||||||
}
|
|
||||||
|
|
||||||
# Credentials aktualisieren
|
|
||||||
PATCH /api/mqtt/credentials/{device_id}
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"regenerate_password": true,
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
|
|
||||||
# Credentials löschen
|
|
||||||
DELETE /api/mqtt/credentials/{device_id}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ACL Rules
|
|
||||||
|
|
||||||
```http
|
|
||||||
# ACL Regeln für Device
|
|
||||||
GET /api/mqtt/acl?device_id=10
|
|
||||||
|
|
||||||
# Neue ACL Regel erstellen
|
|
||||||
POST /api/mqtt/acl
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"device_id": "10",
|
|
||||||
"topic_pattern": "owntracks/10/#",
|
|
||||||
"permission": "readwrite"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ACL Regel löschen
|
|
||||||
DELETE /api/mqtt/acl/{rule_id}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mosquitto Sync
|
|
||||||
|
|
||||||
```http
|
|
||||||
# Sync Status abrufen
|
|
||||||
GET /api/mqtt/sync
|
|
||||||
|
|
||||||
# Sync triggern
|
|
||||||
POST /api/mqtt/sync
|
|
||||||
```
|
|
||||||
|
|
||||||
Alle Endpoints erfordern Admin-Authentifizierung (Role: ADMIN).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Migration von mosquitto-automation
|
|
||||||
|
|
||||||
Wenn du bereits Devices in `mosquitto-automation` hast:
|
|
||||||
|
|
||||||
### Automatische Migration (TODO)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Script erstellen das aus der alten DB liest
|
|
||||||
node scripts/migrate-from-mosquitto-automation.js \
|
|
||||||
--old-db /pfad/zu/mosquitto-automation/data/devices.db
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manuelle Migration
|
|
||||||
|
|
||||||
1. Exportiere Devices aus alter DB:
|
|
||||||
```sql
|
|
||||||
SELECT id, name, username, password_hash, permissions
|
|
||||||
FROM devices
|
|
||||||
WHERE active = 1;
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Erstelle Devices im neuen System über Admin Panel
|
|
||||||
3. Provisioniere MQTT Credentials manuell
|
|
||||||
4. Importiere ACL Regeln
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Architektur
|
|
||||||
|
|
||||||
### Komponenten
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────┐
|
|
||||||
│ Location Tracker App (Next.js) │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │
|
|
||||||
│ │ Admin Panel │ │ MQTT Service │ │ API │ │
|
|
||||||
│ │ /admin/mqtt │ │ Subscriber │ │ Routes │ │
|
|
||||||
│ └──────────────┘ └──────────────┘ └──────────┘ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ └──────────────────┴────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌────▼────┐ │
|
|
||||||
│ │ SQLite │ │
|
|
||||||
│ │ DB │ │
|
|
||||||
│ └────┬────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌────▼────────┐ │
|
|
||||||
│ │ Mosquitto │ │
|
|
||||||
│ │ Sync Service│ │
|
|
||||||
│ └────┬────────┘ │
|
|
||||||
└─────────────────────────┼──────────────────────────┘
|
|
||||||
│
|
|
||||||
┌───────────▼───────────┐
|
|
||||||
│ Mosquitto Broker │
|
|
||||||
│ │
|
|
||||||
│ • password.txt │
|
|
||||||
│ • acl.txt │
|
|
||||||
└───────────┬───────────┘
|
|
||||||
│
|
|
||||||
┌───────────▼───────────┐
|
|
||||||
│ GPS Tracking │
|
|
||||||
│ Devices │
|
|
||||||
│ (OwnTracks) │
|
|
||||||
└───────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Datei-Struktur
|
|
||||||
|
|
||||||
```
|
|
||||||
location-tracker-app/
|
|
||||||
├── app/
|
|
||||||
│ ├── admin/
|
|
||||||
│ │ └── mqtt/
|
|
||||||
│ │ └── page.tsx # MQTT Provisioning UI
|
|
||||||
│ └── api/
|
|
||||||
│ └── mqtt/
|
|
||||||
│ ├── credentials/ # Credentials Management
|
|
||||||
│ ├── acl/ # ACL Management
|
|
||||||
│ └── sync/ # Mosquitto Sync
|
|
||||||
├── lib/
|
|
||||||
│ ├── mqtt-db.ts # MQTT Datenbank Operations
|
|
||||||
│ ├── mqtt-subscriber.ts # MQTT Message Processing
|
|
||||||
│ ├── mosquitto-sync.ts # Config File Generation
|
|
||||||
│ └── startup.ts # Service Initialization
|
|
||||||
├── scripts/
|
|
||||||
│ └── add-mqtt-tables.js # Datenbank Migration
|
|
||||||
├── docker-compose.yml # Docker Setup
|
|
||||||
├── Dockerfile # App Container
|
|
||||||
├── mosquitto.conf # Mosquitto Config
|
|
||||||
└── instrumentation.ts # Next.js Startup Hook
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ Vorteile der Integration
|
|
||||||
|
|
||||||
### Vorher (Separate Systeme)
|
|
||||||
|
|
||||||
```
|
|
||||||
GPS Device → MQTT → n8n → HTTP API → location-tracker-app → UI
|
|
||||||
↓
|
|
||||||
mosquitto-automation (separate)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Probleme:**
|
|
||||||
- n8n als zusätzliche Dependency
|
|
||||||
- Zwei separate Admin Panels
|
|
||||||
- Keine zentrale User/Device Verwaltung
|
|
||||||
- Komplexes Setup
|
|
||||||
|
|
||||||
### Nachher (Integriert)
|
|
||||||
|
|
||||||
```
|
|
||||||
GPS Device → MQTT → location-tracker-app → UI
|
|
||||||
↓
|
|
||||||
(integriertes Provisioning)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Vorteile:**
|
|
||||||
✅ Ein Admin Panel für alles
|
|
||||||
✅ Direkte MQTT Verarbeitung (schneller)
|
|
||||||
✅ Einfaches Docker Compose Setup
|
|
||||||
✅ Zentrale Datenbank
|
|
||||||
✅ Weniger Dependencies
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎉 Fertig!
|
|
||||||
|
|
||||||
Die Integration ist komplett. Du kannst jetzt:
|
|
||||||
|
|
||||||
1. **Devices verwalten** unter `/admin/devices`
|
|
||||||
2. **MQTT Credentials provisionieren** unter `/admin/mqtt`
|
|
||||||
3. **ACL Regeln definieren** im MQTT Panel
|
|
||||||
4. **Zu Mosquitto syncen** mit einem Klick
|
|
||||||
5. **GPS Tracking visualisieren** auf der Hauptseite
|
|
||||||
|
|
||||||
Bei Fragen oder Problemen: Siehe Troubleshooting oder check die Logs.
|
|
||||||
|
|
||||||
Happy Tracking! 🚀📍
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
# n8n Integration Anleitung
|
|
||||||
|
|
||||||
## Übersicht
|
|
||||||
|
|
||||||
Die poc-app verwendet nun eine **Dual-Datenbank-Architektur** mit lokalem SQLite-Caching:
|
|
||||||
|
|
||||||
- **database.sqlite** - Benutzerkonten, Geräteverwaltung (kritische Daten, wenige Schreibvorgänge)
|
|
||||||
- **locations.sqlite** - Standortverfolgung Cache (viele Schreibvorgänge, temporär)
|
|
||||||
|
|
||||||
Diese Architektur bietet:
|
|
||||||
- **Performance**: Schnelle Abfragen aus lokalem SQLite anstatt NocoDB API
|
|
||||||
- **Skalierbarkeit**: Unbegrenzter Verlauf ohne Paginierungslimits
|
|
||||||
- **Resilienz**: Auth-System isoliert von der Tracking-Datenbank
|
|
||||||
- **Flexibilität**: Einfaches Löschen alter Daten
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Erforderliche n8n Workflow-Änderungen
|
|
||||||
|
|
||||||
### Aktueller Flow (ALT)
|
|
||||||
|
|
||||||
```
|
|
||||||
MQTT Trigger (owntracks/#)
|
|
||||||
↓
|
|
||||||
MQTT Location verarbeiten (Parse & Transform)
|
|
||||||
↓
|
|
||||||
Speichere in NocoDB
|
|
||||||
```
|
|
||||||
|
|
||||||
### Neuer Flow (ERFORDERLICH)
|
|
||||||
|
|
||||||
```
|
|
||||||
MQTT Trigger (owntracks/#)
|
|
||||||
↓
|
|
||||||
MQTT Location verarbeiten (Parse & Transform)
|
|
||||||
↓
|
|
||||||
Speichere in NocoDB
|
|
||||||
↓
|
|
||||||
[NEU] Push to Next.js Cache (HTTP Request)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Schritt-für-Schritt: HTTP Request Node hinzufügen
|
|
||||||
|
|
||||||
### 1. HTTP Request Node hinzufügen
|
|
||||||
|
|
||||||
Nach dem "Speichere in NocoDB" Node einen neuen **HTTP Request** Node hinzufügen:
|
|
||||||
|
|
||||||
**Node-Konfiguration:**
|
|
||||||
- **Name**: "Push to Next.js Cache"
|
|
||||||
- **Methode**: POST
|
|
||||||
- **URL**: `https://deine-nextjs-domain.com/api/locations/ingest`
|
|
||||||
- **Authentifizierung**: None (API-Key in Produktion hinzufügen!)
|
|
||||||
- **Body Content Type**: JSON
|
|
||||||
- **Specify Body**: Using Fields Below
|
|
||||||
|
|
||||||
### 2. Felder zuordnen
|
|
||||||
|
|
||||||
Im Bereich "Body Parameters" die folgenden Felder zuordnen:
|
|
||||||
|
|
||||||
| Parameter | Wert (Expression) | Beschreibung |
|
|
||||||
|-----------|-------------------|-------------|
|
|
||||||
| `latitude` | `{{ $json.latitude }}` | Geografischer Breitengrad |
|
|
||||||
| `longitude` | `{{ $json.longitude }}` | Geografischer Längengrad |
|
|
||||||
| `timestamp` | `{{ $json.timestamp }}` | ISO 8601 Zeitstempel |
|
|
||||||
| `user_id` | `{{ $json.user_id }}` | Benutzer-ID (0 für MQTT) |
|
|
||||||
| `first_name` | `{{ $json.first_name }}` | Tracker ID |
|
|
||||||
| `last_name` | `{{ $json.last_name }}` | Quelltyp |
|
|
||||||
| `username` | `{{ $json.username }}` | Geräte-Benutzername |
|
|
||||||
| `marker_label` | `{{ $json.marker_label }}` | Anzeige-Label |
|
|
||||||
| `display_time` | `{{ $json.display_time }}` | Formatierte Zeit |
|
|
||||||
| `chat_id` | `{{ $json.chat_id }}` | Chat-ID (0 für MQTT) |
|
|
||||||
| `battery` | `{{ $json.battery }}` | Batterieprozent |
|
|
||||||
| `speed` | `{{ $json.speed }}` | Geschwindigkeit (m/s) |
|
|
||||||
|
|
||||||
### 3. Fehlerbehandlung (Optional aber empfohlen)
|
|
||||||
|
|
||||||
Einen **Error Trigger** Node hinzufügen, um fehlgeschlagene API-Aufrufe zu behandeln:
|
|
||||||
|
|
||||||
- **Workflow**: Current Workflow
|
|
||||||
- **Error Type**: All Errors
|
|
||||||
- **Connected to**: Push to Next.js Cache node
|
|
||||||
|
|
||||||
Einen **Slack/Email** Benachrichtigungs-Node hinzufügen, um über Fehler informiert zu werden.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Beispiel n8n HTTP Request Node (JSON)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"method": "POST",
|
|
||||||
"url": "https://deine-domain.com/api/locations/ingest",
|
|
||||||
"authentication": "none",
|
|
||||||
"options": {},
|
|
||||||
"bodyParametersJson": "={{ {\n \"latitude\": $json.latitude,\n \"longitude\": $json.longitude,\n \"timestamp\": $json.timestamp,\n \"user_id\": $json.user_id,\n \"first_name\": $json.first_name,\n \"last_name\": $json.last_name,\n \"username\": $json.username,\n \"marker_label\": $json.marker_label,\n \"display_time\": $json.display_time,\n \"chat_id\": $json.chat_id,\n \"battery\": $json.battery,\n \"speed\": $json.speed\n} }}"
|
|
||||||
},
|
|
||||||
"name": "Push to Next.js Cache",
|
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
|
||||||
"typeVersion": 4.1,
|
|
||||||
"position": [1200, 300]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testen
|
|
||||||
|
|
||||||
### 1. Ingest-Endpunkt testen
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST https://deine-domain.com/api/locations/ingest \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"latitude": 48.1351,
|
|
||||||
"longitude": 11.5820,
|
|
||||||
"timestamp": "2024-01-15T10:30:00Z",
|
|
||||||
"user_id": 0,
|
|
||||||
"username": "10",
|
|
||||||
"marker_label": "Test Gerät",
|
|
||||||
"battery": 85,
|
|
||||||
"speed": 2.5
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Erwartete Antwort:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"inserted": 1,
|
|
||||||
"message": "Successfully stored 1 location(s)"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Daten überprüfen
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl https://deine-domain.com/api/locations?username=10&timeRangeHours=1
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Statistiken prüfen
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl https://deine-domain.com/api/locations/ingest
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Produktions-Überlegungen
|
|
||||||
|
|
||||||
### 1. API-Key-Authentifizierung hinzufügen
|
|
||||||
|
|
||||||
**Aktualisieren** von `app/api/locations/ingest/route.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
// API-Key validieren
|
|
||||||
const apiKey = request.headers.get('x-api-key');
|
|
||||||
if (apiKey !== process.env.N8N_API_KEY) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Unauthorized' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... restlicher Code
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**n8n HTTP Request Node aktualisieren:**
|
|
||||||
- Header hinzufügen: `x-api-key` = `dein-secret-key`
|
|
||||||
|
|
||||||
### 2. Automatisches Aufräumen einrichten
|
|
||||||
|
|
||||||
Einen Cron-Job hinzufügen, um alte Daten zu löschen (hält die Datenbankgröße überschaubar):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# /etc/crontab
|
|
||||||
# Tägliche Bereinigung um 2 Uhr morgens - löscht Daten älter als 7 Tage
|
|
||||||
0 2 * * * cd /pfad/zu/poc-app && node scripts/cleanup-old-locations.js 168
|
|
||||||
```
|
|
||||||
|
|
||||||
Oder verwende einen systemd Timer, PM2 Cron oder ähnliches.
|
|
||||||
|
|
||||||
### 3. Datenbankgröße überwachen
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Datenbankstatistiken prüfen
|
|
||||||
curl https://deine-domain.com/api/locations/ingest
|
|
||||||
|
|
||||||
# Erwartete Ausgabe:
|
|
||||||
# {
|
|
||||||
# "total": 5432,
|
|
||||||
# "oldest": "2024-01-08T10:00:00Z",
|
|
||||||
# "newest": "2024-01-15T10:30:00Z",
|
|
||||||
# "sizeKB": 1024
|
|
||||||
# }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Backup-Strategie
|
|
||||||
|
|
||||||
**database.sqlite** (kritisch):
|
|
||||||
- Tägliche automatisierte Backups
|
|
||||||
- 30-Tage Aufbewahrung
|
|
||||||
|
|
||||||
**locations.sqlite** (Cache):
|
|
||||||
- Optional: Wöchentliche Backups
|
|
||||||
- Oder keine Backups (Daten existieren in NocoDB)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration: Vorhandene NocoDB-Daten importieren
|
|
||||||
|
|
||||||
Falls du bereits Standortdaten in NocoDB hast, kannst du diese importieren:
|
|
||||||
|
|
||||||
### Option 1: CSV aus NocoDB exportieren
|
|
||||||
|
|
||||||
1. Daten aus NocoDB als CSV exportieren
|
|
||||||
2. In JSON konvertieren
|
|
||||||
3. In Batches an `/api/locations/ingest` senden (POST)
|
|
||||||
|
|
||||||
### Option 2: Direkter NocoDB API Import (empfohlen)
|
|
||||||
|
|
||||||
Ein Skript `scripts/import-from-nocodb.js` erstellen:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const fetch = require('node-fetch');
|
|
||||||
const { locationDb } = require('../lib/db');
|
|
||||||
|
|
||||||
async function importFromNocoDB() {
|
|
||||||
// Alle Daten von NocoDB API abrufen
|
|
||||||
const response = await fetch('https://n8n.example.com/webhook/location');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Bulk-Insert in locations.sqlite
|
|
||||||
const count = locationDb.createMany(data.history);
|
|
||||||
console.log(`Importiert: ${count} Standorte`);
|
|
||||||
}
|
|
||||||
|
|
||||||
importFromNocoDB().catch(console.error);
|
|
||||||
```
|
|
||||||
|
|
||||||
Ausführen: `node scripts/import-from-nocodb.js`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fehlerbehebung
|
|
||||||
|
|
||||||
### Problem: "directory does not exist" Fehler
|
|
||||||
|
|
||||||
**Lösung**: Init-Skript ausführen:
|
|
||||||
```bash
|
|
||||||
cd poc-app
|
|
||||||
node scripts/init-locations-db.js
|
|
||||||
```
|
|
||||||
|
|
||||||
### Problem: n8n gibt 500-Fehler beim Push zu Next.js zurück
|
|
||||||
|
|
||||||
**Prüfen**:
|
|
||||||
1. Next.js App läuft und ist erreichbar
|
|
||||||
2. URL in n8n ist korrekt
|
|
||||||
3. Next.js Logs prüfen: `pm2 logs` oder `docker logs`
|
|
||||||
|
|
||||||
### Problem: Keine Daten im Frontend sichtbar
|
|
||||||
|
|
||||||
**Überprüfen**:
|
|
||||||
1. Daten sind in SQLite: `curl https://deine-domain.com/api/locations/ingest`
|
|
||||||
2. API gibt Daten zurück: `curl https://deine-domain.com/api/locations`
|
|
||||||
3. Browser-Konsole auf Fehler prüfen
|
|
||||||
|
|
||||||
### Problem: Datenbank wird zu groß
|
|
||||||
|
|
||||||
**Lösung**: Cleanup-Skript ausführen:
|
|
||||||
```bash
|
|
||||||
node scripts/cleanup-old-locations.js 168 # Behalte 7 Tage
|
|
||||||
```
|
|
||||||
|
|
||||||
Oder die Aufbewahrungsdauer im Cron-Job reduzieren.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architektur-Diagramm
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐
|
|
||||||
│ OwnTracks App │
|
|
||||||
└────────┬────────┘
|
|
||||||
│ MQTT
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ MQTT Broker │
|
|
||||||
└────────┬────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ n8n Workflow │
|
|
||||||
│ │
|
|
||||||
│ 1. Parse MQTT │
|
|
||||||
│ 2. Save NocoDB │───────────┐
|
|
||||||
│ 3. Push Next.js│ │
|
|
||||||
└────────┬────────┘ │
|
|
||||||
│ │
|
|
||||||
│ HTTP POST │ (Backup)
|
|
||||||
▼ ▼
|
|
||||||
┌─────────────────┐ ┌──────────────┐
|
|
||||||
│ Next.js API │ │ NocoDB │
|
|
||||||
│ /api/locations │ │ (Cloud DB) │
|
|
||||||
│ /ingest │ └──────────────┘
|
|
||||||
└────────┬────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ locations.sqlite│ (Lokaler Cache)
|
|
||||||
│ - Schnelle │
|
|
||||||
│ Abfragen │
|
|
||||||
│ - Auto Cleanup │
|
|
||||||
│ - Isoliert │
|
|
||||||
└────────┬────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ Frontend API │
|
|
||||||
│ /api/locations │
|
|
||||||
│ (Read-only) │
|
|
||||||
└────────┬────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ Map UI │
|
|
||||||
│ - 24h Verlauf │
|
|
||||||
│ - Schnelle │
|
|
||||||
│ Filter │
|
|
||||||
│ - Echtzeit │
|
|
||||||
└─────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Zusammenfassung
|
|
||||||
|
|
||||||
✅ **Dual-Datenbank** isoliert kritische Auth von hochvolumiger Standortverfolgung
|
|
||||||
✅ **Lokaler Cache** ermöglicht schnelle 24h+ Abfragen ohne NocoDB-Paginierungslimits
|
|
||||||
✅ **WAL-Modus** bietet Absturzsicherheit und bessere Nebenläufigkeit
|
|
||||||
✅ **Auto Cleanup** hält die Datenbankgröße überschaubar
|
|
||||||
✅ **Rückwärtskompatibel** - gleiches API-Antwortformat wie n8n Webhook
|
|
||||||
|
|
||||||
🚀 **Die Next.js App ist jetzt produktionsreif mit unbegrenztem Standortverlauf!**
|
|
||||||
129
README.md
129
README.md
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
Eine moderne Location-Tracking Anwendung basierend auf Next.js 14 mit MQTT/OwnTracks Integration, SQLite-Datenbank, Admin-Panel und Authentifizierung.
|
Eine moderne Location-Tracking Anwendung basierend auf Next.js 14 mit MQTT/OwnTracks Integration, SQLite-Datenbank, Admin-Panel und Authentifizierung.
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 📋 Inhaltsverzeichnis
|
## 📋 Inhaltsverzeichnis
|
||||||
|
|
||||||
- [Features](#-features)
|
- [Features](#-features)
|
||||||
@@ -43,7 +41,6 @@ Eine moderne Location-Tracking Anwendung basierend auf Next.js 14 mit MQTT/OwnTr
|
|||||||
- ⏱️ **System Status** - Live-Uptime, Memory Usage, Runtime Info
|
- ⏱️ **System Status** - Live-Uptime, Memory Usage, Runtime Info
|
||||||
- 📱 **Device Management** - Geräte hinzufügen, bearbeiten, löschen
|
- 📱 **Device Management** - Geräte hinzufügen, bearbeiten, löschen
|
||||||
- 💾 **Datenbank-Wartung**:
|
- 💾 **Datenbank-Wartung**:
|
||||||
- 🔄 Manueller Sync von n8n
|
|
||||||
- 🧹 Cleanup alter Daten (7, 15, 30, 90 Tage)
|
- 🧹 Cleanup alter Daten (7, 15, 30, 90 Tage)
|
||||||
- ⚡ Datenbank-Optimierung (VACUUM)
|
- ⚡ Datenbank-Optimierung (VACUUM)
|
||||||
- 📈 Detaillierte Statistiken
|
- 📈 Detaillierte Statistiken
|
||||||
@@ -61,7 +58,7 @@ Eine moderne Location-Tracking Anwendung basierend auf Next.js 14 mit MQTT/OwnTr
|
|||||||
- **Authentifizierung:** NextAuth.js v5 (beta)
|
- **Authentifizierung:** NextAuth.js v5 (beta)
|
||||||
- **Datenbank:** SQLite (better-sqlite3)
|
- **Datenbank:** SQLite (better-sqlite3)
|
||||||
- **Passwort-Hashing:** bcryptjs
|
- **Passwort-Hashing:** bcryptjs
|
||||||
- **Datenquelle:** n8n Webhook API + lokale SQLite-Cache
|
- **Datenquelle:** MQTT Broker + lokale SQLite-Cache
|
||||||
|
|
||||||
### Dual-Database Architektur
|
### Dual-Database Architektur
|
||||||
- **database.sqlite** - User, Geräte (kritische Daten)
|
- **database.sqlite** - User, Geräte (kritische Daten)
|
||||||
@@ -249,9 +246,9 @@ Passwort: admin123
|
|||||||
In der OwnTracks App:
|
In der OwnTracks App:
|
||||||
- **Tracker ID (tid):** z.B. `12`
|
- **Tracker ID (tid):** z.B. `12`
|
||||||
- **Topic:** `owntracks/user/12`
|
- **Topic:** `owntracks/user/12`
|
||||||
- MQTT Broker wie gewohnt
|
- MQTT Broker konfigurieren (siehe MQTT Setup)
|
||||||
|
|
||||||
Die n8n-Workflow holt die Daten, und die App synct automatisch alle 5 Sekunden.
|
Die App empfängt die Daten direkt vom MQTT Broker.
|
||||||
|
|
||||||
### Zeitfilter verwenden
|
### Zeitfilter verwenden
|
||||||
|
|
||||||
@@ -292,36 +289,29 @@ Für spezifische Zeiträume, z.B. "Route von gestern Abend 18:00 bis heute Morge
|
|||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
A[📱 OwnTracks App] -->|MQTT Publish| B[🔌 MQTT Broker]
|
A[📱 OwnTracks App] -->|MQTT Publish| B[🔌 MQTT Broker]
|
||||||
B -->|Subscribe| C[⚙️ n8n MQTT Trigger]
|
B -->|Subscribe| C[📡 Next.js MQTT Subscriber]
|
||||||
C -->|Store| D[💾 NocoDB]
|
|
||||||
D -->|Webhook API| E[🌐 n8n Webhook<br/>/webhook/location]
|
|
||||||
|
|
||||||
F[🖥️ Browser Client] -->|GET /api/locations<br/>alle 5 Sek| G[📡 Next.js API Route]
|
C -->|Store Locations| D[(🗄️ SQLite Cache<br/>locations.sqlite)]
|
||||||
|
|
||||||
G -->|1. Fetch Fresh Data| E
|
E[🖥️ Browser Client] -->|GET /api/locations<br/>alle 5 Sek| F[📡 Next.js API Route]
|
||||||
E -->|JSON Response| G
|
|
||||||
|
|
||||||
G -->|2. Sync New Locations| H[(🗄️ SQLite Cache<br/>locations.sqlite)]
|
D -->|Query Filtered Data| F
|
||||||
|
F -->|JSON Response| E
|
||||||
|
|
||||||
H -->|3. Query Filtered Data| G
|
E -->|Render| G[🗺️ React Leaflet Map]
|
||||||
G -->|JSON Response| F
|
|
||||||
|
|
||||||
F -->|Render| I[🗺️ React Leaflet Map]
|
H[👤 Admin User] -->|Login| I[🔐 NextAuth.js]
|
||||||
|
I -->|Authenticated| J[📊 Admin Panel]
|
||||||
J[👤 Admin User] -->|Login| K[🔐 NextAuth.js]
|
J -->|CRUD Operations| K[(💼 SQLite DB<br/>database.sqlite)]
|
||||||
K -->|Authenticated| L[📊 Admin Panel]
|
|
||||||
L -->|CRUD Operations| M[(💼 SQLite DB<br/>database.sqlite)]
|
|
||||||
|
|
||||||
style A fill:#4CAF50
|
style A fill:#4CAF50
|
||||||
style B fill:#FF9800
|
style B fill:#FF9800
|
||||||
style C fill:#2196F3
|
style C fill:#2196F3
|
||||||
style D fill:#9C27B0
|
style D fill:#FFC107
|
||||||
style E fill:#F44336
|
style F fill:#00BCD4
|
||||||
style G fill:#00BCD4
|
style G fill:#8BC34A
|
||||||
style H fill:#FFC107
|
style I fill:#E91E63
|
||||||
style I fill:#8BC34A
|
style K fill:#FFC107
|
||||||
style K fill:#E91E63
|
|
||||||
style M fill:#FFC107
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Komponenten-Übersicht
|
### Komponenten-Übersicht
|
||||||
@@ -331,42 +321,39 @@ graph LR
|
|||||||
subgraph "External Services"
|
subgraph "External Services"
|
||||||
A[OwnTracks App]
|
A[OwnTracks App]
|
||||||
B[MQTT Broker]
|
B[MQTT Broker]
|
||||||
C[n8n Automation]
|
|
||||||
D[NocoDB]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Next.js Application"
|
subgraph "Next.js Application"
|
||||||
E[Frontend<br/>React/Leaflet]
|
C[MQTT Subscriber]
|
||||||
F[API Routes]
|
D[Frontend<br/>React/Leaflet]
|
||||||
G[Auth Layer<br/>NextAuth.js]
|
E[API Routes]
|
||||||
|
F[Auth Layer<br/>NextAuth.js]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Data Layer"
|
subgraph "Data Layer"
|
||||||
H[locations.sqlite<br/>Tracking Data]
|
G[locations.sqlite<br/>Tracking Data]
|
||||||
I[database.sqlite<br/>Users & Devices]
|
H[database.sqlite<br/>Users & Devices]
|
||||||
end
|
end
|
||||||
|
|
||||||
A -->|MQTT| B
|
A -->|MQTT| B
|
||||||
B -->|Subscribe| C
|
B -->|Subscribe| C
|
||||||
C -->|Store| D
|
C -->|Write| G
|
||||||
C -->|Webhook| F
|
|
||||||
|
|
||||||
E -->|HTTP| F
|
D -->|HTTP| E
|
||||||
F -->|Read/Write| H
|
E -->|Read/Write| G
|
||||||
F -->|Read/Write| I
|
E -->|Read/Write| H
|
||||||
|
|
||||||
E -->|Auth| G
|
D -->|Auth| F
|
||||||
G -->|Validate| I
|
F -->|Validate| H
|
||||||
|
|
||||||
style A fill:#4CAF50,color:#fff
|
style A fill:#4CAF50,color:#fff
|
||||||
style B fill:#FF9800,color:#fff
|
style B fill:#FF9800,color:#fff
|
||||||
style C fill:#2196F3,color:#fff
|
style C fill:#2196F3,color:#fff
|
||||||
style D fill:#9C27B0,color:#fff
|
style D fill:#00BCD4,color:#000
|
||||||
style E fill:#00BCD4,color:#000
|
style E fill:#00BCD4,color:#000
|
||||||
style F fill:#00BCD4,color:#000
|
style F fill:#E91E63,color:#fff
|
||||||
style G fill:#E91E63,color:#fff
|
style G fill:#FFC107,color:#000
|
||||||
style H fill:#FFC107,color:#000
|
style H fill:#FFC107,color:#000
|
||||||
style I fill:#FFC107,color:#000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Datenbank-Architektur
|
### Datenbank-Architektur
|
||||||
@@ -410,20 +397,21 @@ erDiagram
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Auto-Sync Mechanismus
|
### Auto-Refresh Mechanismus
|
||||||
|
|
||||||
Die App verwendet einen **Hybrid-Ansatz**:
|
Die App verwendet einen **Echtzeit-Ansatz**:
|
||||||
|
|
||||||
1. **Frontend polling** (alle 5 Sek.) → `/api/locations`
|
1. **MQTT Subscriber** empfängt OwnTracks Messages direkt vom Broker
|
||||||
2. **API prüft** ob neue Daten in n8n verfügbar
|
2. **Locations** werden sofort in SQLite gespeichert
|
||||||
3. **Nur neue Locations** werden in SQLite gespeichert
|
3. **Frontend polling** (alle 5 Sek.) → `/api/locations`
|
||||||
4. **Duplikate** werden durch UNIQUE Index verhindert
|
4. **API liest** gefilterte Daten aus lokalem SQLite Cache
|
||||||
5. **Antwort** kommt aus lokalem SQLite Cache
|
5. **Duplikate** werden durch UNIQUE Index verhindert
|
||||||
|
|
||||||
**Vorteile:**
|
**Vorteile:**
|
||||||
- Schnelle Antwortzeiten (SQLite statt n8n)
|
- Echtzeitdaten ohne Verzögerung
|
||||||
|
- Schnelle Antwortzeiten (direkter SQLite Zugriff)
|
||||||
- Längere Zeiträume abrufbar (24h+)
|
- Längere Zeiträume abrufbar (24h+)
|
||||||
- Funktioniert auch wenn n8n nicht erreichbar ist (Fallback auf n8n-Daten)
|
- Keine externen Dependencies (kein n8n nötig)
|
||||||
- Duplikate werden automatisch verhindert (UNIQUE Index)
|
- Duplikate werden automatisch verhindert (UNIQUE Index)
|
||||||
|
|
||||||
### Datenvalidierung & Normalisierung
|
### Datenvalidierung & Normalisierung
|
||||||
@@ -431,18 +419,13 @@ Die App verwendet einen **Hybrid-Ansatz**:
|
|||||||
Die App behandelt spezielle Fälle bei speed/battery korrekt:
|
Die App behandelt spezielle Fälle bei speed/battery korrekt:
|
||||||
|
|
||||||
**speed = 0 Behandlung:**
|
**speed = 0 Behandlung:**
|
||||||
- n8n sendet `speed: 0` (gültig - Gerät steht still)
|
- MQTT sendet `speed: 0` (gültig - Gerät steht still)
|
||||||
- Wird mit `typeof === 'number'` Check explizit als `0` gespeichert
|
- Wird mit `typeof === 'number'` Check explizit als `0` gespeichert
|
||||||
- Wird NICHT zu `null` konvertiert (wichtig für Telemetrie)
|
- Wird NICHT zu `null` konvertiert (wichtig für Telemetrie)
|
||||||
- Popup zeigt "Speed: 0.0 km/h" an
|
- Popup zeigt "Speed: 0.0 km/h" an
|
||||||
|
|
||||||
**n8n Fallback:**
|
|
||||||
- Wenn DB leer ist, gibt API direkt n8n-Daten zurück
|
|
||||||
- Alle Filter (username, timeRangeHours) funktionieren auch mit Fallback
|
|
||||||
- Ermöglicht sofortigen Betrieb ohne DB-Initialisierung
|
|
||||||
|
|
||||||
**Debug-Logging:**
|
**Debug-Logging:**
|
||||||
- Server-Logs zeigen n8n Sync-Aktivität
|
- Server-Logs zeigen MQTT Message-Verarbeitung
|
||||||
- Browser Console zeigt Daten-Flow (MapView, Popup)
|
- Browser Console zeigt Daten-Flow (MapView, Popup)
|
||||||
- Hilfreich für Troubleshooting
|
- Hilfreich für Troubleshooting
|
||||||
|
|
||||||
@@ -453,14 +436,13 @@ Die App behandelt spezielle Fälle bei speed/battery korrekt:
|
|||||||
### Öffentlich
|
### Öffentlich
|
||||||
|
|
||||||
**GET /api/locations**
|
**GET /api/locations**
|
||||||
- Location-Daten abrufen (mit Auto-Sync)
|
- Location-Daten abrufen (aus SQLite Cache)
|
||||||
- Query-Parameter:
|
- Query-Parameter:
|
||||||
- `username` - Device-Filter (z.B. "10", "11")
|
- `username` - Device-Filter (z.B. "10", "11")
|
||||||
- **Zeitfilter (wähle eine Methode):**
|
- **Zeitfilter (wähle eine Methode):**
|
||||||
- `timeRangeHours` - Quick Filter (1, 3, 6, 12, 24)
|
- `timeRangeHours` - Quick Filter (1, 3, 6, 12, 24)
|
||||||
- `startTime` & `endTime` - Custom Range (ISO 8601 Format)
|
- `startTime` & `endTime` - Custom Range (ISO 8601 Format)
|
||||||
- `limit` - Max. Anzahl (Standard: 1000)
|
- `limit` - Max. Anzahl (Standard: 1000)
|
||||||
- `sync=false` - Nur Cache ohne n8n Sync
|
|
||||||
|
|
||||||
**Beispiele:**
|
**Beispiele:**
|
||||||
```bash
|
```bash
|
||||||
@@ -493,10 +475,6 @@ GET /api/locations?username=10&startTime=2025-11-16T16:00:00.000Z&endTime=2025-1
|
|||||||
**DELETE /api/devices/:id**
|
**DELETE /api/devices/:id**
|
||||||
- Gerät löschen (soft delete)
|
- Gerät löschen (soft delete)
|
||||||
|
|
||||||
**POST /api/locations/sync**
|
|
||||||
- Manueller Sync von n8n
|
|
||||||
- Gibt Anzahl neu eingefügter Locations zurück
|
|
||||||
|
|
||||||
**POST /api/locations/cleanup**
|
**POST /api/locations/cleanup**
|
||||||
- Alte Locations löschen
|
- Alte Locations löschen
|
||||||
- Body: `{ retentionHours }`
|
- Body: `{ retentionHours }`
|
||||||
@@ -562,14 +540,6 @@ node scripts/optimize-db.js
|
|||||||
- `VACUUM` - Speicherplatz freigeben
|
- `VACUUM` - Speicherplatz freigeben
|
||||||
- `ANALYZE` - Query-Statistiken aktualisieren
|
- `ANALYZE` - Query-Statistiken aktualisieren
|
||||||
|
|
||||||
### Sync von n8n
|
|
||||||
|
|
||||||
**Via Admin-Panel:**
|
|
||||||
- `/admin` → Database Maintenance → Sync Now
|
|
||||||
|
|
||||||
**Automatisch:**
|
|
||||||
- Passiert alle 5 Sekunden beim Abruf der Karte
|
|
||||||
|
|
||||||
### Logs prüfen
|
### Logs prüfen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -612,9 +582,6 @@ Erstelle `.env.local`:
|
|||||||
# NextAuth
|
# NextAuth
|
||||||
AUTH_SECRET=<generiere-mit-openssl-rand-base64-32>
|
AUTH_SECRET=<generiere-mit-openssl-rand-base64-32>
|
||||||
NEXTAUTH_URL=https://your-domain.com
|
NEXTAUTH_URL=https://your-domain.com
|
||||||
|
|
||||||
# Optional: n8n API URL (Standard in Code definiert)
|
|
||||||
N8N_API_URL=https://n8n.example.com/webhook/location
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Secret generieren:**
|
**Secret generieren:**
|
||||||
@@ -781,9 +748,9 @@ node scripts/init-locations-db.js
|
|||||||
|
|
||||||
### Map zeigt keine Daten
|
### Map zeigt keine Daten
|
||||||
|
|
||||||
1. n8n Webhook erreichbar? `curl https://n8n.example.com/webhook/location`
|
1. MQTT Broker erreichbar? `mosquitto_sub -h localhost -p 1883 -t '#'`
|
||||||
2. Locations in Datenbank? `/admin` → Database Statistics prüfen
|
2. Locations in Datenbank? `/admin` → Database Statistics prüfen
|
||||||
3. Auto-Sync aktiv? Browser Console öffnen
|
3. MQTT Subscriber läuft? Server-Logs prüfen
|
||||||
|
|
||||||
### "ENOENT: no such file or directory, open 'data/database.sqlite'"
|
### "ENOENT: no such file or directory, open 'data/database.sqlite'"
|
||||||
|
|
||||||
@@ -861,7 +828,7 @@ Alle vollständigen Lizenztexte der verwendeten Bibliotheken finden Sie in den j
|
|||||||
- **NextAuth.js** - Authentifizierung
|
- **NextAuth.js** - Authentifizierung
|
||||||
- **better-sqlite3** - SQLite für Node.js
|
- **better-sqlite3** - SQLite für Node.js
|
||||||
- **Tailwind CSS** - Utility-First CSS
|
- **Tailwind CSS** - Utility-First CSS
|
||||||
- **n8n** - Workflow Automation (Backend)
|
- **MQTT.js** - MQTT Client für Node.js
|
||||||
- **OwnTracks** - Location Tracking Apps
|
- **OwnTracks** - Location Tracking Apps
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
143
app/api/export/csv/route.ts
Normal file
143
app/api/export/csv/route.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { locationDb, userDb } from "@/lib/db";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { calculateDistance, RateLimitedGeocoder } from "@/lib/geo-utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/export/csv
|
||||||
|
*
|
||||||
|
* Export location data as CSV for Lexware digital logbook
|
||||||
|
*
|
||||||
|
* Query parameters:
|
||||||
|
* - username: Filter by device tracker ID
|
||||||
|
* - startTime: Custom range start (ISO string)
|
||||||
|
* - endTime: Custom range end (ISO string)
|
||||||
|
* - includeGeocoding: Whether to include addresses (default: false for preview)
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Check authentication
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's allowed device IDs
|
||||||
|
const userId = (session.user as any).id;
|
||||||
|
const role = (session.user as any).role;
|
||||||
|
const sessionUsername = session.user.name || '';
|
||||||
|
|
||||||
|
const userDeviceIds = userDb.getAllowedDeviceIds(userId, role, sessionUsername);
|
||||||
|
|
||||||
|
if (userDeviceIds.length === 0) {
|
||||||
|
return new NextResponse("Keine Geräte verfügbar", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const username = searchParams.get('username') || undefined;
|
||||||
|
const startTime = searchParams.get('startTime') || undefined;
|
||||||
|
const endTime = searchParams.get('endTime') || undefined;
|
||||||
|
const includeGeocoding = searchParams.get('includeGeocoding') === 'true';
|
||||||
|
|
||||||
|
// Fetch locations from database
|
||||||
|
let locations = locationDb.findMany({
|
||||||
|
user_id: 0,
|
||||||
|
username,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
limit: 10000, // High limit for exports
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter by user's allowed devices
|
||||||
|
locations = locations.filter(loc => loc.username && userDeviceIds.includes(loc.username));
|
||||||
|
|
||||||
|
if (locations.length === 0) {
|
||||||
|
return new NextResponse("Keine Daten im gewählten Zeitraum", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort chronologically (oldest first) for proper distance calculation
|
||||||
|
locations.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||||
|
|
||||||
|
// Initialize geocoder if needed
|
||||||
|
const geocoder = includeGeocoding ? new RateLimitedGeocoder() : null;
|
||||||
|
|
||||||
|
// Build CSV
|
||||||
|
const csvRows: string[] = [];
|
||||||
|
|
||||||
|
// Header
|
||||||
|
csvRows.push('Datum,Uhrzeit,Latitude,Longitude,Adresse,Distanz (km),Geschwindigkeit (km/h),Gerät');
|
||||||
|
|
||||||
|
// Process each location
|
||||||
|
for (let i = 0; i < locations.length; i++) {
|
||||||
|
const loc = locations[i];
|
||||||
|
const lat = Number(loc.latitude);
|
||||||
|
const lon = Number(loc.longitude);
|
||||||
|
|
||||||
|
// Calculate distance from previous point
|
||||||
|
let distance = 0;
|
||||||
|
if (i > 0) {
|
||||||
|
const prevLoc = locations[i - 1];
|
||||||
|
distance = calculateDistance(
|
||||||
|
Number(prevLoc.latitude),
|
||||||
|
Number(prevLoc.longitude),
|
||||||
|
lat,
|
||||||
|
lon
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geocode if requested
|
||||||
|
let address = `${lat.toFixed(6)}, ${lon.toFixed(6)}`;
|
||||||
|
if (geocoder) {
|
||||||
|
address = await geocoder.geocode(lat, lon);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format timestamp (German format)
|
||||||
|
const date = new Date(loc.timestamp);
|
||||||
|
const dateStr = date.toLocaleDateString('de-DE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
});
|
||||||
|
const timeStr = date.toLocaleTimeString('de-DE', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Speed (may be null)
|
||||||
|
const speed = loc.speed != null ? Number(loc.speed).toFixed(1) : '';
|
||||||
|
|
||||||
|
// Device name
|
||||||
|
const deviceName = loc.username || 'Unbekannt';
|
||||||
|
|
||||||
|
// Build CSV row - properly escape address field
|
||||||
|
const escapedAddress = address.includes(',') ? `"${address}"` : address;
|
||||||
|
const distanceStr = distance.toFixed(3).replace('.', ','); // German decimal separator
|
||||||
|
|
||||||
|
csvRows.push(
|
||||||
|
`${dateStr},${timeStr},${lat.toFixed(6)},${lon.toFixed(6)},${escapedAddress},${distanceStr},${speed},${deviceName}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const csv = csvRows.join('\n');
|
||||||
|
|
||||||
|
// Return CSV with proper headers
|
||||||
|
const filename = `fahrtenbuch_${startTime ? new Date(startTime).toISOString().split('T')[0] : 'alle'}_${endTime ? new Date(endTime).toISOString().split('T')[0] : 'alle'}.csv`;
|
||||||
|
|
||||||
|
return new NextResponse(csv, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/csv; charset=utf-8',
|
||||||
|
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error exporting CSV:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Failed to export CSV",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error"
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import { locationDb, Location } from '@/lib/db';
|
|||||||
/**
|
/**
|
||||||
* POST /api/locations/ingest
|
* POST /api/locations/ingest
|
||||||
*
|
*
|
||||||
* Endpoint for n8n to push location data to local SQLite cache.
|
* Endpoint for external systems to push location data to local SQLite cache.
|
||||||
* This is called AFTER n8n stores the data in NocoDB.
|
* Can be used for bulk imports or external integrations.
|
||||||
*
|
*
|
||||||
* Expected payload (single location or array):
|
* Expected payload (single location or array):
|
||||||
* {
|
* {
|
||||||
|
|||||||
@@ -3,15 +3,11 @@ import type { LocationResponse } from "@/types/location";
|
|||||||
import { locationDb, Location, deviceDb, userDb } from "@/lib/db";
|
import { locationDb, Location, deviceDb, userDb } from "@/lib/db";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
const N8N_API_URL = process.env.N8N_API_URL || "https://n8n.example.com/webhook/location";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/locations
|
* GET /api/locations
|
||||||
*
|
*
|
||||||
* Hybrid approach:
|
* Fetches location data from local SQLite cache.
|
||||||
* 1. Fetch fresh data from n8n webhook
|
* The MQTT subscriber automatically writes new locations to the cache.
|
||||||
* 2. Store new locations in local SQLite cache
|
|
||||||
* 3. Return filtered data from SQLite (enables 24h+ history)
|
|
||||||
*
|
*
|
||||||
* Query parameters:
|
* Query parameters:
|
||||||
* - username: Filter by device tracker ID
|
* - username: Filter by device tracker ID
|
||||||
@@ -19,7 +15,6 @@ const N8N_API_URL = process.env.N8N_API_URL || "https://n8n.example.com/webhook/
|
|||||||
* - startTime: Custom range start (ISO string)
|
* - startTime: Custom range start (ISO string)
|
||||||
* - endTime: Custom range end (ISO string)
|
* - endTime: Custom range end (ISO string)
|
||||||
* - limit: Maximum number of records (default: 1000)
|
* - limit: Maximum number of records (default: 1000)
|
||||||
* - sync: Set to 'false' to skip n8n fetch and read only from cache
|
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -58,87 +53,8 @@ export async function GET(request: NextRequest) {
|
|||||||
const limit = searchParams.get('limit')
|
const limit = searchParams.get('limit')
|
||||||
? parseInt(searchParams.get('limit')!, 10)
|
? parseInt(searchParams.get('limit')!, 10)
|
||||||
: 1000;
|
: 1000;
|
||||||
const sync = searchParams.get('sync') !== 'false'; // Default: true
|
|
||||||
|
|
||||||
// Variable to store n8n data as fallback
|
// Read from local SQLite with filters
|
||||||
let n8nData: LocationResponse | null = null;
|
|
||||||
|
|
||||||
// Step 1: Optionally fetch and sync from n8n
|
|
||||||
if (sync) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(N8N_API_URL, {
|
|
||||||
cache: "no-store",
|
|
||||||
signal: AbortSignal.timeout(3000), // 3 second timeout
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data: LocationResponse = await response.json();
|
|
||||||
|
|
||||||
// Debug: Log first location from n8n
|
|
||||||
if (data.history && data.history.length > 0) {
|
|
||||||
console.log('[N8N Debug] First location from n8n:', {
|
|
||||||
username: data.history[0].username,
|
|
||||||
speed: data.history[0].speed,
|
|
||||||
speed_type: typeof data.history[0].speed,
|
|
||||||
speed_exists: 'speed' in data.history[0],
|
|
||||||
battery: data.history[0].battery,
|
|
||||||
battery_type: typeof data.history[0].battery,
|
|
||||||
battery_exists: 'battery' in data.history[0]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize data: Ensure speed and battery fields exist (treat 0 explicitly)
|
|
||||||
if (data.history && Array.isArray(data.history)) {
|
|
||||||
data.history = data.history.map(loc => {
|
|
||||||
// Generate display_time in German locale (Europe/Berlin timezone)
|
|
||||||
const displayTime = new Date(loc.timestamp).toLocaleString('de-DE', {
|
|
||||||
timeZone: 'Europe/Berlin',
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit'
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...loc,
|
|
||||||
display_time: displayTime,
|
|
||||||
// Explicit handling: 0 is valid, only undefined/null → null
|
|
||||||
speed: typeof loc.speed === 'number' ? loc.speed : (loc.speed !== undefined && loc.speed !== null ? Number(loc.speed) : null),
|
|
||||||
battery: typeof loc.battery === 'number' ? loc.battery : (loc.battery !== undefined && loc.battery !== null ? Number(loc.battery) : null),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store n8n data for fallback
|
|
||||||
n8nData = data;
|
|
||||||
|
|
||||||
// Store new locations in SQLite
|
|
||||||
if (data.history && Array.isArray(data.history) && data.history.length > 0) {
|
|
||||||
// Get latest timestamp from our DB
|
|
||||||
const stats = locationDb.getStats();
|
|
||||||
const lastLocalTimestamp = stats.newest || '1970-01-01T00:00:00Z';
|
|
||||||
|
|
||||||
// Filter for only newer locations
|
|
||||||
const newLocations = data.history.filter(loc =>
|
|
||||||
loc.timestamp > lastLocalTimestamp
|
|
||||||
);
|
|
||||||
|
|
||||||
if (newLocations.length > 0) {
|
|
||||||
const inserted = locationDb.createMany(newLocations as Location[]);
|
|
||||||
console.log(`[Location Sync] Inserted ${inserted} new locations from n8n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (syncError) {
|
|
||||||
// n8n not reachable - that's ok, we'll use cached data
|
|
||||||
console.warn('[Location Sync] n8n webhook not reachable, using cache only:',
|
|
||||||
syncError instanceof Error ? syncError.message : 'Unknown error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Read from local SQLite with filters
|
|
||||||
let locations = locationDb.findMany({
|
let locations = locationDb.findMany({
|
||||||
user_id: 0, // Always filter for MQTT devices
|
user_id: 0, // Always filter for MQTT devices
|
||||||
username,
|
username,
|
||||||
@@ -151,38 +67,6 @@ export async function GET(request: NextRequest) {
|
|||||||
// Filter locations to only include user's devices
|
// Filter locations to only include user's devices
|
||||||
locations = locations.filter(loc => loc.username && userDeviceIds.includes(loc.username));
|
locations = locations.filter(loc => loc.username && userDeviceIds.includes(loc.username));
|
||||||
|
|
||||||
// Step 3: If DB is empty, use n8n data as fallback
|
|
||||||
if (locations.length === 0 && n8nData && n8nData.history) {
|
|
||||||
console.log('[API] DB empty, using n8n data as fallback');
|
|
||||||
// Filter n8n data if needed
|
|
||||||
let filteredHistory = n8nData.history;
|
|
||||||
|
|
||||||
// Filter by user's devices
|
|
||||||
filteredHistory = filteredHistory.filter(loc => loc.username && userDeviceIds.includes(loc.username));
|
|
||||||
|
|
||||||
if (username) {
|
|
||||||
filteredHistory = filteredHistory.filter(loc => loc.username === username);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply time filters
|
|
||||||
if (startTime && endTime) {
|
|
||||||
// Custom range
|
|
||||||
filteredHistory = filteredHistory.filter(loc =>
|
|
||||||
loc.timestamp >= startTime && loc.timestamp <= endTime
|
|
||||||
);
|
|
||||||
} else if (timeRangeHours) {
|
|
||||||
// Quick filter
|
|
||||||
const cutoffTime = new Date(Date.now() - timeRangeHours * 60 * 60 * 1000).toISOString();
|
|
||||||
filteredHistory = filteredHistory.filter(loc => loc.timestamp >= cutoffTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
...n8nData,
|
|
||||||
history: filteredHistory,
|
|
||||||
total_points: filteredHistory.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize locations: Ensure speed, battery, and display_time are correct
|
// Normalize locations: Ensure speed, battery, and display_time are correct
|
||||||
locations = locations.map(loc => {
|
locations = locations.map(loc => {
|
||||||
// Generate display_time if missing or regenerate from timestamp
|
// Generate display_time if missing or regenerate from timestamp
|
||||||
@@ -207,7 +91,7 @@ export async function GET(request: NextRequest) {
|
|||||||
// Get actual total count from database (not limited by 'limit' parameter)
|
// Get actual total count from database (not limited by 'limit' parameter)
|
||||||
const stats = locationDb.getStats();
|
const stats = locationDb.getStats();
|
||||||
|
|
||||||
// Step 4: Return data in n8n-compatible format
|
// Return data in standard format
|
||||||
const response: LocationResponse = {
|
const response: LocationResponse = {
|
||||||
success: true,
|
success: true,
|
||||||
current: locations.length > 0 ? locations[0] : null,
|
current: locations.length > 0 ? locations[0] : null,
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { auth } from '@/lib/auth';
|
|
||||||
import { locationDb, Location } from '@/lib/db';
|
|
||||||
import type { LocationResponse } from "@/types/location";
|
|
||||||
|
|
||||||
const N8N_API_URL = process.env.N8N_API_URL || "https://n8n.example.com/webhook/location";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/locations/sync (ADMIN only)
|
|
||||||
*
|
|
||||||
* Manually sync location data from n8n webhook to local SQLite cache.
|
|
||||||
* This fetches all available data from n8n and stores only new records.
|
|
||||||
*
|
|
||||||
* Useful for:
|
|
||||||
* - Initial database population
|
|
||||||
* - Recovery after downtime
|
|
||||||
* - Manual refresh
|
|
||||||
*/
|
|
||||||
export async function POST() {
|
|
||||||
try {
|
|
||||||
// ADMIN only
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
// Get stats before sync
|
|
||||||
const statsBefore = locationDb.getStats();
|
|
||||||
|
|
||||||
// Fetch from n8n webhook
|
|
||||||
const response = await fetch(N8N_API_URL, {
|
|
||||||
cache: "no-store",
|
|
||||||
signal: AbortSignal.timeout(10000), // 10 second timeout for manual sync
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`n8n webhook returned ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: LocationResponse = await response.json();
|
|
||||||
|
|
||||||
let insertedCount = 0;
|
|
||||||
|
|
||||||
// Store new locations in SQLite
|
|
||||||
if (data.history && Array.isArray(data.history) && data.history.length > 0) {
|
|
||||||
// Get latest timestamp from our DB
|
|
||||||
const lastLocalTimestamp = statsBefore.newest || '1970-01-01T00:00:00Z';
|
|
||||||
|
|
||||||
// Filter for only newer locations
|
|
||||||
const newLocations = data.history.filter(loc =>
|
|
||||||
loc.timestamp > lastLocalTimestamp
|
|
||||||
);
|
|
||||||
|
|
||||||
if (newLocations.length > 0) {
|
|
||||||
insertedCount = locationDb.createMany(newLocations as Location[]);
|
|
||||||
console.log(`[Manual Sync] Inserted ${insertedCount} new locations from n8n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get stats after sync
|
|
||||||
const statsAfter = locationDb.getStats();
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
synced: insertedCount,
|
|
||||||
n8nTotal: data.total_points || data.history.length,
|
|
||||||
before: {
|
|
||||||
total: statsBefore.total,
|
|
||||||
newest: statsBefore.newest,
|
|
||||||
},
|
|
||||||
after: {
|
|
||||||
total: statsAfter.total,
|
|
||||||
newest: statsAfter.newest,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Sync error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: 'Failed to sync locations',
|
|
||||||
details: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
416
app/export/page.tsx
Normal file
416
app/export/page.tsx
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface Device {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreviewRow {
|
||||||
|
datum: string;
|
||||||
|
uhrzeit: string;
|
||||||
|
latitude: string;
|
||||||
|
longitude: string;
|
||||||
|
adresse: string;
|
||||||
|
distanz: string;
|
||||||
|
geschwindigkeit: string;
|
||||||
|
geraet: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExportPage() {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [devices, setDevices] = useState<Device[]>([]);
|
||||||
|
const [selectedDevice, setSelectedDevice] = useState<string>("all");
|
||||||
|
const [startTime, setStartTime] = useState<string>("");
|
||||||
|
const [endTime, setEndTime] = useState<string>("");
|
||||||
|
const [previewData, setPreviewData] = useState<PreviewRow[]>([]);
|
||||||
|
const [totalPoints, setTotalPoints] = useState<number>(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [exportProgress, setExportProgress] = useState<string>("");
|
||||||
|
|
||||||
|
// Redirect if not authenticated
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "unauthenticated") {
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
}, [status, router]);
|
||||||
|
|
||||||
|
// Fetch devices
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDevices = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/devices/public");
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setDevices(data.devices || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch devices:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status === "authenticated") {
|
||||||
|
fetchDevices();
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
// Set default time range (last 7 days)
|
||||||
|
useEffect(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// Format for datetime-local input (YYYY-MM-DDTHH:mm)
|
||||||
|
setEndTime(now.toISOString().slice(0, 16));
|
||||||
|
setStartTime(sevenDaysAgo.toISOString().slice(0, 16));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Generate preview
|
||||||
|
const handlePreview = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setPreviewData([]);
|
||||||
|
setTotalPoints(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (selectedDevice !== "all") {
|
||||||
|
params.set("username", selectedDevice);
|
||||||
|
}
|
||||||
|
if (startTime) {
|
||||||
|
params.set("startTime", new Date(startTime).toISOString());
|
||||||
|
}
|
||||||
|
if (endTime) {
|
||||||
|
params.set("endTime", new Date(endTime).toISOString());
|
||||||
|
}
|
||||||
|
params.set("limit", "50"); // Preview only first 50
|
||||||
|
|
||||||
|
const response = await fetch(`/api/locations?${params.toString()}`);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch preview");
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const locations = data.history || [];
|
||||||
|
setTotalPoints(locations.length);
|
||||||
|
|
||||||
|
// Sort chronologically
|
||||||
|
locations.sort((a: any, b: any) =>
|
||||||
|
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build preview rows (without geocoding)
|
||||||
|
const rows: PreviewRow[] = [];
|
||||||
|
for (let i = 0; i < Math.min(locations.length, 50); i++) {
|
||||||
|
const loc = locations[i];
|
||||||
|
const lat = Number(loc.latitude);
|
||||||
|
const lon = Number(loc.longitude);
|
||||||
|
|
||||||
|
// Calculate distance
|
||||||
|
let distance = 0;
|
||||||
|
if (i > 0) {
|
||||||
|
const prevLoc = locations[i - 1];
|
||||||
|
distance = calculateDistance(
|
||||||
|
Number(prevLoc.latitude),
|
||||||
|
Number(prevLoc.longitude),
|
||||||
|
lat,
|
||||||
|
lon
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(loc.timestamp);
|
||||||
|
rows.push({
|
||||||
|
datum: date.toLocaleDateString('de-DE'),
|
||||||
|
uhrzeit: date.toLocaleTimeString('de-DE'),
|
||||||
|
latitude: lat.toFixed(6),
|
||||||
|
longitude: lon.toFixed(6),
|
||||||
|
adresse: `${lat.toFixed(6)}, ${lon.toFixed(6)}`,
|
||||||
|
distanz: distance.toFixed(3),
|
||||||
|
geschwindigkeit: loc.speed != null ? Number(loc.speed).toFixed(1) : '-',
|
||||||
|
geraet: loc.username || 'Unbekannt',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewData(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Preview error:", error);
|
||||||
|
alert("Fehler beim Laden der Vorschau");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export CSV with geocoding
|
||||||
|
const handleExport = async () => {
|
||||||
|
if (previewData.length === 0) {
|
||||||
|
alert("Bitte zuerst Vorschau laden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = confirm(
|
||||||
|
`${totalPoints} GPS-Punkte werden exportiert.\n\n` +
|
||||||
|
`ACHTUNG: Adressauflösung (Geocoding) kann bei vielen Punkten sehr lange dauern (ca. 1 Punkt pro Sekunde).\n\n` +
|
||||||
|
`Geschätzte Dauer: ca. ${Math.ceil(totalPoints / 60)} Minuten.\n\n` +
|
||||||
|
`Möchten Sie fortfahren?`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
setExporting(true);
|
||||||
|
setExportProgress(`Starte Export von ${totalPoints} Punkten...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (selectedDevice !== "all") {
|
||||||
|
params.set("username", selectedDevice);
|
||||||
|
}
|
||||||
|
if (startTime) {
|
||||||
|
params.set("startTime", new Date(startTime).toISOString());
|
||||||
|
}
|
||||||
|
if (endTime) {
|
||||||
|
params.set("endTime", new Date(endTime).toISOString());
|
||||||
|
}
|
||||||
|
params.set("includeGeocoding", "true");
|
||||||
|
|
||||||
|
setExportProgress("Lade Daten und löse Adressen auf...");
|
||||||
|
|
||||||
|
const response = await fetch(`/api/export/csv?${params.toString()}`);
|
||||||
|
if (!response.ok) throw new Error("Export failed");
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `fahrtenbuch_${startTime ? new Date(startTime).toISOString().split('T')[0] : 'alle'}_${endTime ? new Date(endTime).toISOString().split('T')[0] : 'alle'}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
|
||||||
|
setExportProgress("Export erfolgreich abgeschlossen!");
|
||||||
|
setTimeout(() => setExportProgress(""), 3000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Export error:", error);
|
||||||
|
alert("Fehler beim Exportieren");
|
||||||
|
setExportProgress("");
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export CSV without geocoding (faster)
|
||||||
|
const handleQuickExport = async () => {
|
||||||
|
if (previewData.length === 0) {
|
||||||
|
alert("Bitte zuerst Vorschau laden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExporting(true);
|
||||||
|
setExportProgress("Exportiere ohne Adressauflösung...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (selectedDevice !== "all") {
|
||||||
|
params.set("username", selectedDevice);
|
||||||
|
}
|
||||||
|
if (startTime) {
|
||||||
|
params.set("startTime", new Date(startTime).toISOString());
|
||||||
|
}
|
||||||
|
if (endTime) {
|
||||||
|
params.set("endTime", new Date(endTime).toISOString());
|
||||||
|
}
|
||||||
|
params.set("includeGeocoding", "false");
|
||||||
|
|
||||||
|
const response = await fetch(`/api/export/csv?${params.toString()}`);
|
||||||
|
if (!response.ok) throw new Error("Export failed");
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `fahrtenbuch_schnell_${startTime ? new Date(startTime).toISOString().split('T')[0] : 'alle'}_${endTime ? new Date(endTime).toISOString().split('T')[0] : 'alle'}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
|
||||||
|
setExportProgress("Export erfolgreich abgeschlossen!");
|
||||||
|
setTimeout(() => setExportProgress(""), 3000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Export error:", error);
|
||||||
|
alert("Fehler beim Exportieren");
|
||||||
|
setExportProgress("");
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<p>Laden...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 p-6">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">CSV-Export für Fahrtenbuch</h1>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Exportieren Sie Ihre GPS-Tracking-Daten für Lexware oder andere Fahrtenbuch-Software
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Gerät</label>
|
||||||
|
<select
|
||||||
|
value={selectedDevice}
|
||||||
|
onChange={(e) => setSelectedDevice(e.target.value)}
|
||||||
|
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||||
|
>
|
||||||
|
<option value="all">Alle Geräte</option>
|
||||||
|
{devices.map((dev) => (
|
||||||
|
<option key={dev.id} value={dev.id}>
|
||||||
|
{dev.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Von</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={startTime}
|
||||||
|
onChange={(e) => setStartTime(e.target.value)}
|
||||||
|
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Bis</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={endTime}
|
||||||
|
onChange={(e) => setEndTime(e.target.value)}
|
||||||
|
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-3 mb-6">
|
||||||
|
<button
|
||||||
|
onClick={handlePreview}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400"
|
||||||
|
>
|
||||||
|
{loading ? "Lädt..." : "Vorschau laden"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleQuickExport}
|
||||||
|
disabled={exporting || previewData.length === 0}
|
||||||
|
className="px-6 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:bg-gray-400"
|
||||||
|
>
|
||||||
|
Schnell-Export (ohne Adressen)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={exporting || previewData.length === 0}
|
||||||
|
className="px-6 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:bg-gray-400"
|
||||||
|
>
|
||||||
|
Vollständiger Export (mit Adressen)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
{exportProgress && (
|
||||||
|
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded">
|
||||||
|
<p className="text-blue-800">{exportProgress}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
{totalPoints > 0 && (
|
||||||
|
<div className="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded">
|
||||||
|
<p className="text-sm text-yellow-800">
|
||||||
|
<strong>Gefunden:</strong> {totalPoints} GPS-Punkte im gewählten Zeitraum
|
||||||
|
{totalPoints > 50 && " (Vorschau zeigt erste 50)"}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-yellow-800 mt-1">
|
||||||
|
<strong>Hinweis:</strong> Der vollständige Export mit Adressauflösung dauert ca. {Math.ceil(totalPoints / 60)} Minuten.
|
||||||
|
Für schnelle Exporte nutzen Sie den Schnell-Export.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview Table */}
|
||||||
|
{previewData.length > 0 && (
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h2 className="text-xl font-bold mb-4">Vorschau (erste 50 Zeilen)</h2>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Datum</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Uhrzeit</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Latitude</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Longitude</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Adresse</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Distanz (km)</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Geschw. (km/h)</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Gerät</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{previewData.map((row, idx) => (
|
||||||
|
<tr key={idx} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 text-sm">{row.datum}</td>
|
||||||
|
<td className="px-4 py-3 text-sm">{row.uhrzeit}</td>
|
||||||
|
<td className="px-4 py-3 text-sm font-mono text-xs">{row.latitude}</td>
|
||||||
|
<td className="px-4 py-3 text-sm font-mono text-xs">{row.longitude}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600">{row.adresse}</td>
|
||||||
|
<td className="px-4 py-3 text-sm">{row.distanz}</td>
|
||||||
|
<td className="px-4 py-3 text-sm">{row.geschwindigkeit}</td>
|
||||||
|
<td className="px-4 py-3 text-sm">{row.geraet}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Haversine distance calculation (client-side for preview)
|
||||||
|
function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const R = 6371;
|
||||||
|
const dLat = toRadians(lat2 - lat1);
|
||||||
|
const dLon = toRadians(lon2 - lon1);
|
||||||
|
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(toRadians(lat1)) *
|
||||||
|
Math.cos(toRadians(lat2)) *
|
||||||
|
Math.sin(dLon / 2) *
|
||||||
|
Math.sin(dLon / 2);
|
||||||
|
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
return R * c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRadians(degrees: number): number {
|
||||||
|
return degrees * (Math.PI / 180);
|
||||||
|
}
|
||||||
@@ -69,12 +69,20 @@ export default function MapPage() {
|
|||||||
{/* Top row: Title and Admin link */}
|
{/* Top row: Title and Admin link */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-lg sm:text-xl font-bold text-black">Location Tracker</h1>
|
<h1 className="text-lg sm:text-xl font-bold text-black">Location Tracker</h1>
|
||||||
<a
|
<div className="flex gap-2">
|
||||||
href="/admin"
|
<a
|
||||||
className="px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors whitespace-nowrap"
|
href="/export"
|
||||||
>
|
className="px-3 py-1 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors whitespace-nowrap"
|
||||||
Admin
|
>
|
||||||
</a>
|
📥 Export
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin"
|
||||||
|
className="px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls row - responsive grid */}
|
{/* Controls row - responsive grid */}
|
||||||
|
|||||||
@@ -157,7 +157,6 @@ export default function MapView({ selectedDevice, timeFilter, isPaused, filterMo
|
|||||||
}
|
}
|
||||||
|
|
||||||
params.set("limit", "5000"); // Fetch more data for better history
|
params.set("limit", "5000"); // Fetch more data for better history
|
||||||
params.set("sync", "false"); // Disable n8n sync (using direct MQTT)
|
|
||||||
|
|
||||||
// Fetch from local SQLite API (MQTT subscriber writes directly)
|
// Fetch from local SQLite API (MQTT subscriber writes directly)
|
||||||
const response = await fetch(`/api/locations?${params.toString()}`);
|
const response = await fetch(`/api/locations?${params.toString()}`);
|
||||||
|
|||||||
111
lib/geo-utils.ts
Normal file
111
lib/geo-utils.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Geo utilities for distance calculation and geocoding
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate distance between two GPS coordinates using Haversine formula
|
||||||
|
* @param lat1 Latitude of first point
|
||||||
|
* @param lon1 Longitude of first point
|
||||||
|
* @param lat2 Latitude of second point
|
||||||
|
* @param lon2 Longitude of second point
|
||||||
|
* @returns Distance in kilometers
|
||||||
|
*/
|
||||||
|
export function calculateDistance(
|
||||||
|
lat1: number,
|
||||||
|
lon1: number,
|
||||||
|
lat2: number,
|
||||||
|
lon2: number
|
||||||
|
): number {
|
||||||
|
const R = 6371; // Earth's radius in kilometers
|
||||||
|
const dLat = toRadians(lat2 - lat1);
|
||||||
|
const dLon = toRadians(lon2 - lon1);
|
||||||
|
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(toRadians(lat1)) *
|
||||||
|
Math.cos(toRadians(lat2)) *
|
||||||
|
Math.sin(dLon / 2) *
|
||||||
|
Math.sin(dLon / 2);
|
||||||
|
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
const distance = R * c;
|
||||||
|
|
||||||
|
return distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRadians(degrees: number): number {
|
||||||
|
return degrees * (Math.PI / 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse geocode coordinates to address using Nominatim (OpenStreetMap)
|
||||||
|
* @param lat Latitude
|
||||||
|
* @param lon Longitude
|
||||||
|
* @returns Address string or coordinates if geocoding fails
|
||||||
|
*/
|
||||||
|
export async function reverseGeocode(
|
||||||
|
lat: number,
|
||||||
|
lon: number
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
const url = `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lon}&zoom=18&addressdetails=1`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'GPS-Tracker-App/1.0', // Nominatim requires User-Agent
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Nominatim API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
return `${lat.toFixed(6)}, ${lon.toFixed(6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build address from components
|
||||||
|
const address = data.address;
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (address.road) parts.push(address.road);
|
||||||
|
if (address.house_number) parts.push(address.house_number);
|
||||||
|
if (address.postcode) parts.push(address.postcode);
|
||||||
|
if (address.city || address.town || address.village) {
|
||||||
|
parts.push(address.city || address.town || address.village);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts.join(', ') : data.display_name;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Geocoding error:', error);
|
||||||
|
// Fallback to coordinates
|
||||||
|
return `${lat.toFixed(6)}, ${lon.toFixed(6)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate-limited reverse geocoding (max 1 request per second for Nominatim)
|
||||||
|
*/
|
||||||
|
export class RateLimitedGeocoder {
|
||||||
|
private lastRequestTime = 0;
|
||||||
|
private minInterval = 1000; // 1 second between requests
|
||||||
|
|
||||||
|
async geocode(lat: number, lon: number): Promise<string> {
|
||||||
|
const now = Date.now();
|
||||||
|
const timeSinceLastRequest = now - this.lastRequestTime;
|
||||||
|
|
||||||
|
if (timeSinceLastRequest < this.minInterval) {
|
||||||
|
const waitTime = this.minInterval - timeSinceLastRequest;
|
||||||
|
await this.sleep(waitTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastRequestTime = Date.now();
|
||||||
|
return reverseGeocode(lat, lon);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 60 KiB |
Reference in New Issue
Block a user