chore: standardize service domains and error logging
Build and Push Docker Images / detect-changes (push) Successful in 15s
Build and Push Docker Images / build-auth-api (push) Successful in 1m2s
Build and Push Docker Images / build-employee-api (push) Successful in 1m2s
Build and Push Docker Images / build-transit-api (push) Successful in 1m2s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m10s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m22s
Build and Push Docker Images / deploy-test-node (push) Successful in 4s
Build and Push Docker Images / detect-changes (push) Successful in 15s
Build and Push Docker Images / build-auth-api (push) Successful in 1m2s
Build and Push Docker Images / build-employee-api (push) Successful in 1m2s
Build and Push Docker Images / build-transit-api (push) Successful in 1m2s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m10s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m22s
Build and Push Docker Images / deploy-test-node (push) Successful in 4s
This commit is contained in:
+20
-6
@@ -3,12 +3,14 @@
|
|||||||
## 🚀 5-Minute Setup
|
## 🚀 5-Minute Setup
|
||||||
|
|
||||||
### Step 1: Initial Setup
|
### Step 1: Initial Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# From the project root
|
# From the project root
|
||||||
./scripts/init.sh
|
./scripts/init.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This will:
|
This will:
|
||||||
|
|
||||||
- Check Docker and Docker Compose installation
|
- Check Docker and Docker Compose installation
|
||||||
- Create `.env` file from template
|
- Create `.env` file from template
|
||||||
- Build all Docker images
|
- Build all Docker images
|
||||||
@@ -17,6 +19,7 @@ This will:
|
|||||||
### Step 2: Configure Database Connection
|
### Step 2: Configure Database Connection
|
||||||
|
|
||||||
Edit `.env` with your database details:
|
Edit `.env` with your database details:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
POSTGRES_HOST=your-db-host.com
|
POSTGRES_HOST=your-db-host.com
|
||||||
POSTGRES_USER_AUTH=transitAUTH
|
POSTGRES_USER_AUTH=transitAUTH
|
||||||
@@ -28,11 +31,13 @@ JWT_SECRET=your-secure-secret
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Step 3: Start Services
|
### Step 3: Start Services
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/deploy.sh start
|
./scripts/deploy.sh start
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 4: Access Admin UI
|
### Step 4: Access Admin UI
|
||||||
|
|
||||||
```
|
```
|
||||||
http://localhost:5174
|
http://localhost:5174
|
||||||
```
|
```
|
||||||
@@ -42,29 +47,32 @@ Log in with your real user code and password.
|
|||||||
## 📱 Admin UI Features
|
## 📱 Admin UI Features
|
||||||
|
|
||||||
### Stops Management
|
### Stops Management
|
||||||
|
|
||||||
- Create new transit stops
|
- Create new transit stops
|
||||||
- Set geolocation (latitude, longitude)
|
- Set geolocation (latitude, longitude)
|
||||||
- View all stops
|
- View all stops
|
||||||
- Link stops to bus lines
|
- Link stops to bus lines
|
||||||
|
|
||||||
### Bus Lines
|
### Bus Lines
|
||||||
|
|
||||||
- Create new bus routes
|
- Create new bus routes
|
||||||
- Define start and end stops
|
- Define start and end stops
|
||||||
- Set estimated travel time
|
- Set estimated travel time
|
||||||
- Assign stops to lines with order
|
- Assign stops to lines with order
|
||||||
|
|
||||||
### Fleet
|
### Fleet
|
||||||
|
|
||||||
- View fleet vehicles (read-only)
|
- View fleet vehicles (read-only)
|
||||||
- See vehicle details
|
- See vehicle details
|
||||||
|
|
||||||
## 🐳 Docker Services
|
## 🐳 Docker Services
|
||||||
|
|
||||||
| Port | Service | URL |
|
| Port | Service | URL |
|
||||||
|------|---------|-----|
|
| ---- | ------------ | ---------------------------------------- |
|
||||||
| 5174 | Admin UI | http://localhost:5174 |
|
| 5174 | Admin UI | http://localhost:5174 |
|
||||||
| 5000 | Auth API | http://localhost:5000 |
|
| 5000 | Auth API | https://auth.xtransit.testinstance.nl |
|
||||||
| 5001 | Transit API | http://localhost:5001 |
|
| 5001 | Transit API | https://transit.xtransit.testinstance.nl |
|
||||||
| 4000 | Employee API | http://localhost:4000 |
|
| 4000 | Employee API | http://localhost:4000 |
|
||||||
|
|
||||||
## 🔧 Common Commands
|
## 🔧 Common Commands
|
||||||
|
|
||||||
@@ -91,6 +99,7 @@ Log in with your real user code and password.
|
|||||||
Use the real login flow with user code and password.
|
Use the real login flow with user code and password.
|
||||||
|
|
||||||
### Production
|
### Production
|
||||||
|
|
||||||
- Keep `DEV_BYPASS=0` and `DEV_MOCK=0`
|
- Keep `DEV_BYPASS=0` and `DEV_MOCK=0`
|
||||||
- Configure proper admin user codes in `ADMIN_USER_CODES`
|
- Configure proper admin user codes in `ADMIN_USER_CODES`
|
||||||
- Ensure JWTs include the `userCode` claim
|
- Ensure JWTs include the `userCode` claim
|
||||||
@@ -105,6 +114,7 @@ Use the real login flow with user code and password.
|
|||||||
## ⚠️ Troubleshooting
|
## ⚠️ Troubleshooting
|
||||||
|
|
||||||
### "Address already in use"
|
### "Address already in use"
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Find and stop the process using the port
|
# Find and stop the process using the port
|
||||||
lsof -i :5174 # Find process on port 5174
|
lsof -i :5174 # Find process on port 5174
|
||||||
@@ -112,6 +122,7 @@ kill -9 <PID> # Kill the process
|
|||||||
```
|
```
|
||||||
|
|
||||||
### "Cannot connect to database"
|
### "Cannot connect to database"
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check database credentials in .env
|
# Check database credentials in .env
|
||||||
# Verify database is accessible from your machine
|
# Verify database is accessible from your machine
|
||||||
@@ -119,6 +130,7 @@ kill -9 <PID> # Kill the process
|
|||||||
```
|
```
|
||||||
|
|
||||||
### "Admin UI not loading"
|
### "Admin UI not loading"
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check container is running
|
# Check container is running
|
||||||
docker-compose ps admin_ui
|
docker-compose ps admin_ui
|
||||||
@@ -131,7 +143,9 @@ docker-compose restart admin_ui
|
|||||||
```
|
```
|
||||||
|
|
||||||
### "Permission denied" errors
|
### "Permission denied" errors
|
||||||
|
|
||||||
The transit API user needs proper database permissions:
|
The transit API user needs proper database permissions:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- Grant permissions to transitAPI user
|
-- Grant permissions to transitAPI user
|
||||||
GRANT CONNECT ON DATABASE "XTransit" TO transitAPI;
|
GRANT CONNECT ON DATABASE "XTransit" TO transitAPI;
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ Dit repository bevat een suite van Node.js microservices, een admin webapp en ee
|
|||||||
- `employee_api` — Employee onboarding / management (standaard port 4000)
|
- `employee_api` — Employee onboarding / management (standaard port 4000)
|
||||||
- `transit_api` — Transit/schedule endpoints (standaard port 5001)
|
- `transit_api` — Transit/schedule endpoints (standaard port 5001)
|
||||||
- `error_log_api` — Centrale foutlogging en viewer (standaard port 5003)
|
- `error_log_api` — Centrale foutlogging en viewer (standaard port 5003)
|
||||||
|
|
||||||
|
De error API is beschikbaar via `https://error.xtransit.testinstance.nl`.
|
||||||
|
|
||||||
- `admin_ui` — React + Vite beheerinterface
|
- `admin_ui` — React + Vite beheerinterface
|
||||||
- `deprecated/` — legacy Flutter bus terminal, retained for reference while moving the client to a Windows XP-era build path
|
- `deprecated/` — legacy Flutter bus terminal, retained for reference while moving the client to a Windows XP-era build path
|
||||||
|
|
||||||
@@ -88,7 +91,7 @@ POSTGRES_PASSWORD_ERROR_LOG=strong_password_error_log
|
|||||||
JWT_SECRET=super_secret_jwt_key_here
|
JWT_SECRET=super_secret_jwt_key_here
|
||||||
|
|
||||||
# Optioneel: centraal error log endpoint voor clients/services
|
# Optioneel: centraal error log endpoint voor clients/services
|
||||||
ERROR_LOG_API_URL=http://error_log_api:5003/error_log
|
ERROR_LOG_API_URL=https://error.xtransit.testinstance.nl/error_log
|
||||||
```
|
```
|
||||||
|
|
||||||
Opmerking: de exacte variabelen worden gelezen door `docker-compose.yml`. Pas de namen/waarden aan naar jouw omgeving.
|
Opmerking: de exacte variabelen worden gelezen door `docker-compose.yml`. Pas de namen/waarden aan naar jouw omgeving.
|
||||||
@@ -243,8 +246,8 @@ CREATE TABLE IF NOT EXISTS data.errors (
|
|||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
"date" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
"date" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
"service" TEXT NOT NULL,
|
"service" TEXT NOT NULL,
|
||||||
"partOfService" TEXT,
|
partofservice TEXT,
|
||||||
"error" TEXT NOT NULL
|
error TEXT NOT NULL
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -356,7 +359,7 @@ The Transit API exposes public (unauthenticated) schedule endpoints:
|
|||||||
**Example:**
|
**Example:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:5001/schedules?date=2026-05-06
|
curl https://transit.xtransit.testinstance.nl/schedules?date=2026-05-06
|
||||||
```
|
```
|
||||||
|
|
||||||
**TimetableViewer Component:**
|
**TimetableViewer Component:**
|
||||||
|
|||||||
+7
-7
@@ -46,9 +46,9 @@ npm install
|
|||||||
Create a `.env` file in the `admin_ui` directory:
|
Create a `.env` file in the `admin_ui` directory:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
VITE_AUTH_API=http://localhost:5000
|
VITE_AUTH_API=https://auth.xtransit.testinstance.nl
|
||||||
VITE_TRANSIT_API=http://localhost:5001
|
VITE_TRANSIT_API=https://transit.xtransit.testinstance.nl
|
||||||
VITE_ERROR_LOG_API=http://localhost:5003
|
VITE_ERROR_LOG_API=https://error.xtransit.testinstance.nl
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run Development Server
|
### Run Development Server
|
||||||
@@ -102,10 +102,10 @@ http://localhost:5174
|
|||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
| ------------------ | -------------------------------------------------------- | --------------------- |
|
| ------------------ | --------------- | ---------------------------------------- |
|
||||||
| `VITE_AUTH_API` | Auth API URL (same-origin path recommended in Docker) | http://localhost:5000 |
|
| `VITE_AUTH_API` | Auth API URL | https://auth.xtransit.testinstance.nl |
|
||||||
| `VITE_TRANSIT_API` | Transit API URL (same-origin path recommended in Docker) | http://localhost:5001 |
|
| `VITE_TRANSIT_API` | Transit API URL | https://transit.xtransit.testinstance.nl |
|
||||||
|
|
||||||
### Runtime Configuration
|
### Runtime Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ The schedule manager uses:
|
|||||||
### Create a schedule (Admin)
|
### Create a schedule (Admin)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:5001/admin/schedules \
|
curl -X POST https://transit.xtransit.testinstance.nl/admin/schedules \
|
||||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
@@ -261,13 +261,13 @@ curl -X POST http://localhost:5001/admin/schedules \
|
|||||||
### Get today's schedules (Public)
|
### Get today's schedules (Public)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:5001/schedules?date=$(date +%Y-%m-%d)
|
curl https://transit.xtransit.testinstance.nl/schedules?date=$(date +%Y-%m-%d)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Get schedules for specific line (Public)
|
### Get schedules for specific line (Public)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:5001/schedules?lineId=3
|
curl https://transit.xtransit.testinstance.nl/schedules?lineId=3
|
||||||
```
|
```
|
||||||
|
|
||||||
## Future Enhancements
|
## Future Enhancements
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
version: '3.8'
|
version: "3.8"
|
||||||
services:
|
services:
|
||||||
admin-ui:
|
admin-ui:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "5174:80"
|
- "5174:80"
|
||||||
environment:
|
environment:
|
||||||
VITE_AUTH_API: ${VITE_AUTH_API:-http://localhost:5000}
|
VITE_AUTH_API: ${VITE_AUTH_API:-https://auth.xtransit.testinstance.nl}
|
||||||
VITE_TRANSIT_API: ${VITE_TRANSIT_API:-http://localhost:5001}
|
VITE_TRANSIT_API: ${VITE_TRANSIT_API:-https://transit.xtransit.testinstance.nl}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ set -e
|
|||||||
|
|
||||||
cat > /usr/share/nginx/html/env-config.js <<EOF
|
cat > /usr/share/nginx/html/env-config.js <<EOF
|
||||||
window.__ENV__ = {
|
window.__ENV__ = {
|
||||||
AUTH_API: "${AUTH_API:-http://localhost:5000}",
|
AUTH_API: "${AUTH_API:-https://auth.xtransit.testinstance.nl}",
|
||||||
TRANSIT_API: "${TRANSIT_API:-http://localhost:5001}",
|
TRANSIT_API: "${TRANSIT_API:-https://transit.xtransit.testinstance.nl}",
|
||||||
ERROR_LOG_API: "${ERROR_LOG_API:-http://localhost:5003}"
|
ERROR_LOG_API: "${ERROR_LOG_API:-https://error.xtransit.testinstance.nl}"
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|||||||
+69
-50
@@ -1,87 +1,106 @@
|
|||||||
import axios from 'axios'
|
import axios from "axios";
|
||||||
|
|
||||||
const RUNTIME = typeof window !== 'undefined' && window.__ENV__ ? window.__ENV__ : {}
|
const RUNTIME =
|
||||||
|
typeof window !== "undefined" && window.__ENV__ ? window.__ENV__ : {};
|
||||||
|
|
||||||
const normalizeApiBaseUrl = (value, fallbackPath) => {
|
const normalizeApiBaseUrl = (value, fallbackPath) => {
|
||||||
const rawValue = value || ''
|
const rawValue = value || "";
|
||||||
if (!rawValue) return fallbackPath
|
if (!rawValue) return fallbackPath;
|
||||||
if (/^https?:\/\//i.test(rawValue) || rawValue.startsWith('//') || rawValue.startsWith('/')) {
|
if (
|
||||||
return rawValue
|
/^https?:\/\//i.test(rawValue) ||
|
||||||
|
rawValue.startsWith("//") ||
|
||||||
|
rawValue.startsWith("/")
|
||||||
|
) {
|
||||||
|
return rawValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${window.location.protocol}//${rawValue}`
|
return `${window.location.protocol}//${rawValue}`;
|
||||||
}
|
};
|
||||||
|
|
||||||
const AUTH_API = normalizeApiBaseUrl(RUNTIME.AUTH_API || import.meta.env.VITE_AUTH_API, 'http://localhost:5000')
|
const AUTH_API = normalizeApiBaseUrl(
|
||||||
const TRANSIT_API = normalizeApiBaseUrl(RUNTIME.TRANSIT_API || import.meta.env.VITE_TRANSIT_API, 'http://localhost:5001')
|
RUNTIME.AUTH_API || import.meta.env.VITE_AUTH_API,
|
||||||
const ERROR_LOG_API = normalizeApiBaseUrl(RUNTIME.ERROR_LOG_API || import.meta.env.VITE_ERROR_LOG_API, 'http://localhost:5003')
|
"https://auth.xtransit.testinstance.nl",
|
||||||
|
);
|
||||||
|
const TRANSIT_API = normalizeApiBaseUrl(
|
||||||
|
RUNTIME.TRANSIT_API || import.meta.env.VITE_TRANSIT_API,
|
||||||
|
"https://transit.xtransit.testinstance.nl",
|
||||||
|
);
|
||||||
|
const ERROR_LOG_API = normalizeApiBaseUrl(
|
||||||
|
RUNTIME.ERROR_LOG_API || import.meta.env.VITE_ERROR_LOG_API,
|
||||||
|
"https://error.xtransit.testinstance.nl",
|
||||||
|
);
|
||||||
|
|
||||||
const reportClientError = async ({ service, partOfService, error }) => {
|
const reportClientError = async ({ service, partofservice, error }) => {
|
||||||
try {
|
try {
|
||||||
await fetch(`${ERROR_LOG_API}/error_log`, {
|
await fetch(`${ERROR_LOG_API}/error_log`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
service,
|
service,
|
||||||
partOfService,
|
partofservice,
|
||||||
error: typeof error === 'string' ? error : JSON.stringify(error),
|
error: typeof error === "string" ? error : JSON.stringify(error),
|
||||||
}),
|
}),
|
||||||
})
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore logging failures
|
// ignore logging failures
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export const authClient = axios.create({ baseURL: AUTH_API })
|
export const authClient = axios.create({ baseURL: AUTH_API });
|
||||||
|
|
||||||
authClient.interceptors.response.use(
|
authClient.interceptors.response.use(
|
||||||
response => response,
|
(response) => response,
|
||||||
error => {
|
(error) => {
|
||||||
void reportClientError({
|
void reportClientError({
|
||||||
service: 'admin_ui',
|
service: "admin_ui",
|
||||||
partOfService: 'authClient',
|
partofservice: "authClient",
|
||||||
error: error?.response?.data?.error || error?.message || 'authClient_error',
|
error:
|
||||||
})
|
error?.response?.data?.error || error?.message || "authClient_error",
|
||||||
return Promise.reject(error)
|
});
|
||||||
}
|
return Promise.reject(error);
|
||||||
)
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const transitClient = (token) => {
|
export const transitClient = (token) => {
|
||||||
const client = axios.create({
|
const client = axios.create({
|
||||||
baseURL: TRANSIT_API,
|
baseURL: TRANSIT_API,
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
});
|
||||||
|
|
||||||
// Auto-logout on unauthorized responses to avoid delayed failures
|
// Auto-logout on unauthorized responses to avoid delayed failures
|
||||||
client.interceptors.response.use(
|
client.interceptors.response.use(
|
||||||
response => response,
|
(response) => response,
|
||||||
error => {
|
(error) => {
|
||||||
try {
|
try {
|
||||||
const status = error?.response?.status
|
const status = error?.response?.status;
|
||||||
if (status === 401 || status === 403) {
|
if (status === 401 || status === 403) {
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem("token");
|
||||||
localStorage.removeItem('user')
|
localStorage.removeItem("user");
|
||||||
// reload to let App show the Login screen
|
// reload to let App show the Login screen
|
||||||
if (typeof window !== 'undefined') window.location.reload()
|
if (typeof window !== "undefined") window.location.reload();
|
||||||
}
|
}
|
||||||
void reportClientError({
|
void reportClientError({
|
||||||
service: 'admin_ui',
|
service: "admin_ui",
|
||||||
partOfService: 'transitClient',
|
partofservice: "transitClient",
|
||||||
error: error?.response?.data?.error || error?.message || 'transitClient_error',
|
error:
|
||||||
})
|
error?.response?.data?.error ||
|
||||||
|
error?.message ||
|
||||||
|
"transitClient_error",
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error);
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
return client
|
return client;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const errorLogClient = (token) => axios.create({
|
export const errorLogClient = (token) =>
|
||||||
baseURL: ERROR_LOG_API,
|
axios.create({
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
baseURL: ERROR_LOG_API,
|
||||||
})
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
|
||||||
export { reportClientError }
|
export { reportClientError };
|
||||||
|
|||||||
@@ -1,42 +1,44 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react'
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { errorLogClient } from '../api'
|
import { errorLogClient } from "../api";
|
||||||
|
|
||||||
const formatDate = (value) => {
|
const formatDate = (value) => {
|
||||||
if (!value) return '—'
|
if (!value) return "—";
|
||||||
try {
|
try {
|
||||||
return new Date(value).toLocaleString()
|
return new Date(value).toLocaleString();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return String(value)
|
return String(value);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function ErrorLogViewer({ token }) {
|
export default function ErrorLogViewer({ token }) {
|
||||||
const client = useMemo(() => errorLogClient(token), [token])
|
const client = useMemo(() => errorLogClient(token), [token]);
|
||||||
const [logs, setLogs] = useState([])
|
const [logs, setLogs] = useState([]);
|
||||||
const [service, setService] = useState('')
|
const [service, setService] = useState("");
|
||||||
const [partOfService, setPartOfService] = useState('')
|
const [partofservice, setPartofservice] = useState("");
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false);
|
||||||
const [message, setMessage] = useState(null)
|
const [message, setMessage] = useState(null);
|
||||||
|
|
||||||
const loadLogs = async () => {
|
const loadLogs = async () => {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
setMessage(null)
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
const params = { limit: 100 }
|
const params = { limit: 100 };
|
||||||
if (service) params.service = service
|
if (service) params.service = service;
|
||||||
if (partOfService) params.partOfService = partOfService
|
if (partofservice) params.partofservice = partofservice;
|
||||||
const res = await client.get('/error_log', { params })
|
const res = await client.get("/error_log", { params });
|
||||||
setLogs(res.data.errors || [])
|
setLogs(res.data.errors || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessage(err.response?.data?.error || err.message || 'Kon fouten niet laden')
|
setMessage(
|
||||||
|
err.response?.data?.error || err.message || "Kon fouten niet laden",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadLogs()
|
loadLogs();
|
||||||
}, [service, partOfService])
|
}, [service, partofservice]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
@@ -46,29 +48,41 @@ export default function ErrorLogViewer({ token }) {
|
|||||||
<p>Laatste foutmeldingen uit de centrale logging service.</p>
|
<p>Laatste foutmeldingen uit de centrale logging service.</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="secondary" onClick={loadLogs} disabled={loading}>
|
<button className="secondary" onClick={loadLogs} disabled={loading}>
|
||||||
{loading ? 'Laden...' : 'Ververs'}
|
{loading ? "Laden..." : "Ververs"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<input placeholder="Service filter" value={service} onChange={e => setService(e.target.value)} />
|
<input
|
||||||
<input placeholder="Part of service filter" value={partOfService} onChange={e => setPartOfService(e.target.value)} />
|
placeholder="Service filter"
|
||||||
|
value={service}
|
||||||
|
onChange={(e) => setService(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="Part of service filter"
|
||||||
|
value={partofservice}
|
||||||
|
onChange={(e) => setPartofservice(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{message && <div className="message">{message}</div>}
|
{message && <div className="message">{message}</div>}
|
||||||
|
|
||||||
<div className="list-grid">
|
<div className="list-grid">
|
||||||
{logs.map(log => (
|
{logs.map((log) => (
|
||||||
<article className="mini-card" key={log.id}>
|
<article className="mini-card" key={log.id}>
|
||||||
<strong>{log.service}</strong>
|
<strong>{log.service}</strong>
|
||||||
<div>{formatDate(log.date)}</div>
|
<div>{formatDate(log.date)}</div>
|
||||||
<div className="muted" style={{ marginTop: 6 }}>{log.partOfService || '—'}</div>
|
<div className="muted" style={{ marginTop: 6 }}>
|
||||||
|
{log.partofservice || "—"}
|
||||||
|
</div>
|
||||||
<div style={{ marginTop: 8 }}>{log.error}</div>
|
<div style={{ marginTop: 8 }}>{log.error}</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!logs.length && !message && <div className="muted">Nog geen foutmeldingen gevonden.</div>}
|
{!logs.length && !message && (
|
||||||
|
<div className="muted">Nog geen foutmeldingen gevonden.</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +1,85 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }) {
|
export default function TimetableViewer({
|
||||||
const [schedules, setSchedules] = useState([])
|
apiBaseUrl = "https://transit.xtransit.testinstance.nl",
|
||||||
const [summary, setSummary] = useState(null)
|
}) {
|
||||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0])
|
const [schedules, setSchedules] = useState([]);
|
||||||
const [selectedLineId, setSelectedLineId] = useState('')
|
const [summary, setSummary] = useState(null);
|
||||||
const [lines, setLines] = useState([])
|
const [selectedDate, setSelectedDate] = useState(
|
||||||
const [loading, setLoading] = useState(false)
|
new Date().toISOString().split("T")[0],
|
||||||
const [error, setError] = useState(null)
|
);
|
||||||
|
const [selectedLineId, setSelectedLineId] = useState("");
|
||||||
|
const [lines, setLines] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
// Fetch available bus lines
|
// Fetch available bus lines
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchLines = async () => {
|
const fetchLines = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${apiBaseUrl}/admin/bus-lines`)
|
const res = await fetch(`${apiBaseUrl}/admin/bus-lines`);
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
setLines(data.busLines || [])
|
setLines(data.busLines || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load lines:', err)
|
console.error("Failed to load lines:", err);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
fetchLines()
|
fetchLines();
|
||||||
}, [apiBaseUrl])
|
}, [apiBaseUrl]);
|
||||||
|
|
||||||
// Fetch schedules based on date and line filter
|
// Fetch schedules based on date and line filter
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchSchedules = async () => {
|
const fetchSchedules = async () => {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
setError(null)
|
setError(null);
|
||||||
try {
|
try {
|
||||||
let url = `${apiBaseUrl}/schedules/overview?date=${selectedDate}`
|
let url = `${apiBaseUrl}/schedules/overview?date=${selectedDate}`;
|
||||||
if (selectedLineId) {
|
if (selectedLineId) {
|
||||||
url += `&lineId=${selectedLineId}`
|
url += `&lineId=${selectedLineId}`;
|
||||||
}
|
}
|
||||||
const res = await fetch(url)
|
const res = await fetch(url);
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
setSchedules(data.schedules || [])
|
setSchedules(data.schedules || []);
|
||||||
setSummary(data.summary || null)
|
setSummary(data.summary || null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to load schedules: ' + err.message)
|
setError("Failed to load schedules: " + err.message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
fetchSchedules()
|
fetchSchedules();
|
||||||
}, [selectedDate, selectedLineId, apiBaseUrl])
|
}, [selectedDate, selectedLineId, apiBaseUrl]);
|
||||||
|
|
||||||
const timeSlots = generateTimeSlots()
|
const timeSlots = generateTimeSlots();
|
||||||
const schedulesBySlot = groupSchedulesByTimeSlot(schedules, timeSlots)
|
const schedulesBySlot = groupSchedulesByTimeSlot(schedules, timeSlots);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto' }}>
|
<div style={{ padding: "20px", maxWidth: "1200px", margin: "0 auto" }}>
|
||||||
<h1>Public Transit Timetable</h1>
|
<h1>Public Transit Timetable</h1>
|
||||||
|
|
||||||
<div style={{ marginBottom: '20px', display: 'flex', gap: '10px' }}>
|
<div style={{ marginBottom: "20px", display: "flex", gap: "10px" }}>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={selectedDate}
|
value={selectedDate}
|
||||||
onChange={e => setSelectedDate(e.target.value)}
|
onChange={(e) => setSelectedDate(e.target.value)}
|
||||||
style={{ padding: '8px', borderRadius: '6px', border: '1px solid #ccc' }}
|
style={{
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={selectedLineId}
|
value={selectedLineId}
|
||||||
onChange={e => setSelectedLineId(e.target.value)}
|
onChange={(e) => setSelectedLineId(e.target.value)}
|
||||||
style={{ padding: '8px', borderRadius: '6px', border: '1px solid #ccc', minWidth: '200px' }}
|
style={{
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
minWidth: "200px",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<option value="">All lines</option>
|
<option value="">All lines</option>
|
||||||
{lines.map(line => (
|
{lines.map((line) => (
|
||||||
<option key={line.id} value={line.id}>
|
<option key={line.id} value={line.id}>
|
||||||
Line {line.lineNumber} ({line.startStop} → {line.endStop})
|
Line {line.lineNumber} ({line.startStop} → {line.endStop})
|
||||||
</option>
|
</option>
|
||||||
@@ -75,101 +88,191 @@ export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{summary && (
|
{summary && (
|
||||||
<div style={{ marginBottom: '16px', display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
<div
|
||||||
<span style={{ padding: '6px 10px', borderRadius: '999px', backgroundColor: '#eef2ff', color: '#3730a3' }}>
|
style={{
|
||||||
|
marginBottom: "16px",
|
||||||
|
display: "flex",
|
||||||
|
gap: "12px",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: "6px 10px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
backgroundColor: "#eef2ff",
|
||||||
|
color: "#3730a3",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Total: {summary.total}
|
Total: {summary.total}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ padding: '6px 10px', borderRadius: '999px', backgroundColor: '#ecfeff', color: '#155e75' }}>
|
<span
|
||||||
|
style={{
|
||||||
|
padding: "6px 10px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
backgroundColor: "#ecfeff",
|
||||||
|
color: "#155e75",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Vast: {summary.recurring}
|
Vast: {summary.recurring}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ padding: '6px 10px', borderRadius: '999px', backgroundColor: '#f8fafc', color: '#334155' }}>
|
<span
|
||||||
|
style={{
|
||||||
|
padding: "6px 10px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
backgroundColor: "#f8fafc",
|
||||||
|
color: "#334155",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Eenmalig: {summary.oneTime}
|
Eenmalig: {summary.oneTime}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading && <p>Loading schedules...</p>}
|
{loading && <p>Loading schedules...</p>}
|
||||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
{error && <p style={{ color: "red" }}>{error}</p>}
|
||||||
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '20px' }}>
|
<table
|
||||||
<thead style={{ backgroundColor: '#f1f5f9' }}>
|
style={{ width: "100%", borderCollapse: "collapse", marginTop: "20px" }}
|
||||||
|
>
|
||||||
|
<thead style={{ backgroundColor: "#f1f5f9" }}>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Time</th>
|
<th
|
||||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Line</th>
|
style={{
|
||||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Vehicle</th>
|
padding: "12px",
|
||||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Day status</th>
|
textAlign: "left",
|
||||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Delay</th>
|
borderBottom: "2px solid #ddd",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Time
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
padding: "12px",
|
||||||
|
textAlign: "left",
|
||||||
|
borderBottom: "2px solid #ddd",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Line
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
padding: "12px",
|
||||||
|
textAlign: "left",
|
||||||
|
borderBottom: "2px solid #ddd",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Vehicle
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
padding: "12px",
|
||||||
|
textAlign: "left",
|
||||||
|
borderBottom: "2px solid #ddd",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Day status
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
padding: "12px",
|
||||||
|
textAlign: "left",
|
||||||
|
borderBottom: "2px solid #ddd",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delay
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{timeSlots.map((slot, idx) => {
|
{timeSlots.map((slot, idx) => {
|
||||||
const slotSchedules = schedulesBySlot[slot] || []
|
const slotSchedules = schedulesBySlot[slot] || [];
|
||||||
if (slotSchedules.length === 0) return null
|
if (slotSchedules.length === 0) return null;
|
||||||
return slotSchedules.map((schedule, sidx) => (
|
return slotSchedules.map((schedule, sidx) => (
|
||||||
<tr key={`${idx}-${sidx}`} style={{ borderBottom: '1px solid #eee' }}>
|
<tr
|
||||||
<td style={{ padding: '12px' }}>
|
key={`${idx}-${sidx}`}
|
||||||
{sidx === 0 ? slot : ''}
|
style={{ borderBottom: "1px solid #eee" }}
|
||||||
</td>
|
>
|
||||||
<td style={{ padding: '12px', fontWeight: 'bold', color: '#4f46e5' }}>
|
<td style={{ padding: "12px" }}>{sidx === 0 ? slot : ""}</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "12px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#4f46e5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Line {schedule.lineNumber}
|
Line {schedule.lineNumber}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '12px' }}>
|
<td style={{ padding: "12px" }}>
|
||||||
{schedule.busName} ({schedule.licensePlate})
|
{schedule.busName} ({schedule.licensePlate})
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '12px' }}>
|
<td style={{ padding: "12px" }}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
padding: '4px 8px',
|
padding: "4px 8px",
|
||||||
borderRadius: '4px',
|
borderRadius: "4px",
|
||||||
backgroundColor: schedule.status === 'in_progress' ? '#dbeafe' : schedule.status === 'completed' ? '#dcfce7' : '#f3f4f6',
|
backgroundColor:
|
||||||
color: schedule.status === 'in_progress' ? '#1d4ed8' : schedule.status === 'completed' ? '#166534' : '#374151',
|
schedule.status === "in_progress"
|
||||||
fontSize: '0.9rem',
|
? "#dbeafe"
|
||||||
|
: schedule.status === "completed"
|
||||||
|
? "#dcfce7"
|
||||||
|
: "#f3f4f6",
|
||||||
|
color:
|
||||||
|
schedule.status === "in_progress"
|
||||||
|
? "#1d4ed8"
|
||||||
|
: schedule.status === "completed"
|
||||||
|
? "#166534"
|
||||||
|
: "#374151",
|
||||||
|
fontSize: "0.9rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{schedule.status}
|
{schedule.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '12px' }}>
|
<td style={{ padding: "12px" }}>
|
||||||
{typeof schedule.currentDelayMinutes === 'number' && schedule.currentDelayMinutes !== null
|
{typeof schedule.currentDelayMinutes === "number" &&
|
||||||
? `${schedule.currentDelayMinutes > 0 ? '+' : ''}${schedule.currentDelayMinutes} min`
|
schedule.currentDelayMinutes !== null
|
||||||
: '—'}
|
? `${schedule.currentDelayMinutes > 0 ? "+" : ""}${schedule.currentDelayMinutes} min`
|
||||||
|
: "—"}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
));
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{schedules.length === 0 && !loading && (
|
{schedules.length === 0 && !loading && (
|
||||||
<p style={{ textAlign: 'center', color: '#999', marginTop: '40px' }}>
|
<p style={{ textAlign: "center", color: "#999", marginTop: "40px" }}>
|
||||||
No schedules found for the selected date and line.
|
No schedules found for the selected date and line.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateTimeSlots() {
|
function generateTimeSlots() {
|
||||||
const slots = []
|
const slots = [];
|
||||||
for (let hour = 0; hour < 24; hour++) {
|
for (let hour = 0; hour < 24; hour++) {
|
||||||
slots.push(`${String(hour).padStart(2, '0')}:00-${String((hour + 1) % 24).padStart(2, '0')}:00`)
|
slots.push(
|
||||||
|
`${String(hour).padStart(2, "0")}:00-${String((hour + 1) % 24).padStart(2, "0")}:00`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return slots
|
return slots;
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupSchedulesByTimeSlot(schedules, slots) {
|
function groupSchedulesByTimeSlot(schedules, slots) {
|
||||||
const grouped = {}
|
const grouped = {};
|
||||||
slots.forEach(slot => {
|
slots.forEach((slot) => {
|
||||||
grouped[slot] = []
|
grouped[slot] = [];
|
||||||
})
|
});
|
||||||
|
|
||||||
schedules.forEach(schedule => {
|
schedules.forEach((schedule) => {
|
||||||
const startHour = new Date(schedule.startTime).getHours()
|
const startHour = new Date(schedule.startTime).getHours();
|
||||||
const slot = `${String(startHour).padStart(2, '0')}:00-${String((startHour + 1) % 24).padStart(2, '0')}:00`
|
const slot = `${String(startHour).padStart(2, "0")}:00-${String((startHour + 1) % 24).padStart(2, "0")}:00`;
|
||||||
if (grouped[slot]) {
|
if (grouped[slot]) {
|
||||||
grouped[slot].push(schedule)
|
grouped[slot].push(schedule);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
return grouped
|
return grouped;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,32 +215,50 @@ export default function WeeklyScheduleEditor({
|
|||||||
|
|
||||||
{message && <div className="message">{message}</div>}
|
{message && <div className="message">{message}</div>}
|
||||||
|
|
||||||
{DAY_GROUPS.map((group) => (
|
<div
|
||||||
<div key={group.value} style={{ marginTop: "1.5rem" }}>
|
style={{
|
||||||
<h4>{group.label}</h4>
|
display: "grid",
|
||||||
{(groupedSchedules[group.value] || []).length === 0 ? (
|
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
|
||||||
<p>No schedules configured.</p>
|
gap: "1rem",
|
||||||
) : (
|
marginTop: "1.5rem",
|
||||||
<div style={{ display: "grid", gap: "0.75rem" }}>
|
alignItems: "start",
|
||||||
{groupedSchedules[group.value].map((schedule) => (
|
}}
|
||||||
<article key={schedule.id} className="schedule-card">
|
>
|
||||||
<div className="schedule-header">
|
{DAY_GROUPS.map((group) => (
|
||||||
<strong>Line {schedule.lineNumber}</strong>
|
<section
|
||||||
<span className="schedule-time">
|
key={group.value}
|
||||||
{String(schedule.startTime).slice(0, 5)} -{" "}
|
style={{
|
||||||
{String(schedule.endTime).slice(0, 5)}
|
minWidth: 0,
|
||||||
</span>
|
border: "1px solid #dbe3ef",
|
||||||
</div>
|
borderRadius: "8px",
|
||||||
<div className="schedule-details">
|
background: "#f8fafc",
|
||||||
{dayLabel(schedule.dayOfWeek)}: {schedule.startStop} to{" "}
|
padding: "0.75rem",
|
||||||
{schedule.endStop}
|
}}
|
||||||
</div>
|
>
|
||||||
<div className="form-row" style={{ marginTop: "0.75rem" }}>
|
<h4 style={{ marginTop: 0 }}>{group.label}</h4>
|
||||||
|
{(groupedSchedules[group.value] || []).length === 0 ? (
|
||||||
|
<p>No schedules configured.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "grid", gap: "0.75rem" }}>
|
||||||
|
{groupedSchedules[group.value].map((schedule) => (
|
||||||
|
<article key={schedule.id} className="schedule-card">
|
||||||
|
<div className="schedule-header">
|
||||||
|
<strong>Line {schedule.lineNumber}</strong>
|
||||||
|
<span className="schedule-time">
|
||||||
|
{String(schedule.startTime).slice(0, 5)} -{" "}
|
||||||
|
{String(schedule.endTime).slice(0, 5)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="schedule-details">
|
||||||
|
{dayLabel(schedule.dayOfWeek)}: {schedule.startStop} to{" "}
|
||||||
|
{schedule.endStop}
|
||||||
|
</div>
|
||||||
<select
|
<select
|
||||||
value={schedule.driverId || ""}
|
value={schedule.driverId || ""}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
assignDriver(schedule.id, event.target.value)
|
assignDriver(schedule.id, event.target.value)
|
||||||
}
|
}
|
||||||
|
style={{ width: "100%", marginTop: "0.75rem" }}
|
||||||
>
|
>
|
||||||
<option value="">Unassigned</option>
|
<option value="">Unassigned</option>
|
||||||
{drivers.map((driver) => (
|
{drivers.map((driver) => (
|
||||||
@@ -253,16 +271,17 @@ export default function WeeklyScheduleEditor({
|
|||||||
type="button"
|
type="button"
|
||||||
className="danger"
|
className="danger"
|
||||||
onClick={() => removeSchedule(schedule.id)}
|
onClick={() => removeSchedule(schedule.id)}
|
||||||
|
style={{ width: "100%", marginTop: "0.5rem" }}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</article>
|
||||||
</article>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</section>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-39
@@ -1,41 +1,43 @@
|
|||||||
const express = require('express');
|
const express = require("express");
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require("bcryptjs");
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require("jsonwebtoken");
|
||||||
const db = require('./db');
|
const db = require("./db");
|
||||||
require('dotenv').config();
|
require("dotenv").config();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// Simple CORS middleware for browser-based admin UI testing.
|
// Simple CORS middleware for browser-based admin UI testing.
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
|
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
||||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === "OPTIONS") {
|
||||||
return res.sendStatus(204);
|
return res.sendStatus(204);
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
const ERROR_LOG_API_URL = process.env.ERROR_LOG_API_URL || 'http://localhost:5003/error_log';
|
const ERROR_LOG_API_URL =
|
||||||
|
process.env.ERROR_LOG_API_URL ||
|
||||||
|
"https://error.xtransit.testinstance.nl/error_log";
|
||||||
|
|
||||||
function friendlyErrorMessage(statusCode) {
|
function friendlyErrorMessage(statusCode) {
|
||||||
if (statusCode === 400) return 'Controleer je invoer en probeer opnieuw.';
|
if (statusCode === 400) return "Controleer je invoer en probeer opnieuw.";
|
||||||
if (statusCode === 401) return 'Je sessie is verlopen. Log opnieuw in.';
|
if (statusCode === 401) return "Je sessie is verlopen. Log opnieuw in.";
|
||||||
if (statusCode === 403) return 'Je hebt hier geen toegang toe.';
|
if (statusCode === 403) return "Je hebt hier geen toegang toe.";
|
||||||
if (statusCode === 404) return 'Niet gevonden.';
|
if (statusCode === 404) return "Niet gevonden.";
|
||||||
return 'Er ging iets mis. Probeer het later opnieuw.';
|
return "Er ging iets mis. Probeer het later opnieuw.";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reportError({ partOfService, error }) {
|
async function reportError({ partofservice, error }) {
|
||||||
try {
|
try {
|
||||||
await fetch(ERROR_LOG_API_URL, {
|
await fetch(ERROR_LOG_API_URL, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
service: 'auth_api',
|
service: "auth_api",
|
||||||
partOfService,
|
partofservice,
|
||||||
error,
|
error,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -45,9 +47,13 @@ async function reportError({ partOfService, error }) {
|
|||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const originalJson = res.json.bind(res);
|
const originalJson = res.json.bind(res);
|
||||||
res.json = (body) => {
|
res.json = (body) => {
|
||||||
if (res.statusCode >= 400 && body && typeof body === 'object') {
|
if (res.statusCode >= 400 && body && typeof body === "object") {
|
||||||
const rawError = body.error || body.message || body.details || `HTTP ${res.statusCode}`;
|
const rawError =
|
||||||
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: rawError });
|
body.error || body.message || body.details || `HTTP ${res.statusCode}`;
|
||||||
|
void reportError({
|
||||||
|
partofservice: `${req.method} ${req.originalUrl}`,
|
||||||
|
error: rawError,
|
||||||
|
});
|
||||||
return originalJson({ error: friendlyErrorMessage(res.statusCode) });
|
return originalJson({ error: friendlyErrorMessage(res.statusCode) });
|
||||||
}
|
}
|
||||||
return originalJson(body);
|
return originalJson(body);
|
||||||
@@ -58,46 +64,59 @@ app.use((req, res, next) => {
|
|||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET;
|
const JWT_SECRET = process.env.JWT_SECRET;
|
||||||
const ADMIN_USER_CODES = new Set(
|
const ADMIN_USER_CODES = new Set(
|
||||||
String(process.env.ADMIN_USER_CODES || '')
|
String(process.env.ADMIN_USER_CODES || "")
|
||||||
.split(',')
|
.split(",")
|
||||||
.map(c => Number(c.trim()))
|
.map((c) => Number(c.trim()))
|
||||||
.filter(Number.isFinite)
|
.filter(Number.isFinite),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.post("/login", async (req, res) => {
|
||||||
app.post('/login', async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const { userCode, password } = req.body;
|
const { userCode, password } = req.body;
|
||||||
if (!userCode || !password) return res.status(400).json({ error: 'userCode and password required' });
|
if (!userCode || !password)
|
||||||
|
return res.status(400).json({ error: "userCode and password required" });
|
||||||
|
|
||||||
const result = await db.query('SELECT id, usercode, password FROM workers.users WHERE usercode = $1', [userCode]);
|
const result = await db.query(
|
||||||
|
"SELECT id, usercode, password FROM workers.users WHERE usercode = $1",
|
||||||
|
[userCode],
|
||||||
|
);
|
||||||
const user = result.rows[0];
|
const user = result.rows[0];
|
||||||
if (!user) return res.status(401).json({ error: 'invalid_credentials' });
|
if (!user) return res.status(401).json({ error: "invalid_credentials" });
|
||||||
|
|
||||||
const ok = await bcrypt.compare(password, user.password);
|
const ok = await bcrypt.compare(password, user.password);
|
||||||
if (!ok) return res.status(401).json({ error: 'invalid_credentials' });
|
if (!ok) return res.status(401).json({ error: "invalid_credentials" });
|
||||||
|
|
||||||
const isAdmin = ADMIN_USER_CODES.has(Number(user.usercode));
|
const isAdmin = ADMIN_USER_CODES.has(Number(user.usercode));
|
||||||
const payload = { userId: user.id, userCode: user.usercode, isAdmin };
|
const payload = { userId: user.id, userCode: user.usercode, isAdmin };
|
||||||
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' });
|
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: "1h" });
|
||||||
|
|
||||||
res.json({ token, user: { id: user.id, userCode: user.usercode, job: user.job, isAdmin } });
|
res.json({
|
||||||
|
token,
|
||||||
|
user: { id: user.id, userCode: user.usercode, job: user.job, isAdmin },
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(err) });
|
await reportError({
|
||||||
|
partofservice: `${req.method} ${req.originalUrl}`,
|
||||||
|
error: err?.stack || err?.message || String(err),
|
||||||
|
});
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(500).json({ error: friendlyErrorMessage(500) });
|
res.status(500).json({ error: friendlyErrorMessage(500) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/me', async (req, res) => {
|
app.get("/me", async (req, res) => {
|
||||||
const auth = req.headers.authorization;
|
const auth = req.headers.authorization;
|
||||||
if (!auth || !auth.startsWith('Bearer ')) return res.status(401).json({ error: 'missing_token' });
|
if (!auth || !auth.startsWith("Bearer "))
|
||||||
|
return res.status(401).json({ error: "missing_token" });
|
||||||
const token = auth.slice(7);
|
const token = auth.slice(7);
|
||||||
try {
|
try {
|
||||||
const payload = jwt.verify(token, JWT_SECRET);
|
const payload = jwt.verify(token, JWT_SECRET);
|
||||||
res.json({ user: payload });
|
res.json({ user: payload });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(err) });
|
void reportError({
|
||||||
|
partofservice: `${req.method} ${req.originalUrl}`,
|
||||||
|
error: err?.stack || err?.message || String(err),
|
||||||
|
});
|
||||||
res.status(401).json({ error: friendlyErrorMessage(401) });
|
res.status(401).json({ error: friendlyErrorMessage(401) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+5
-5
@@ -12,7 +12,7 @@ services:
|
|||||||
PG_DB: ${POSTGRES_DB}
|
PG_DB: ${POSTGRES_DB}
|
||||||
PG_USER: ${POSTGRES_USER_LOGGER}
|
PG_USER: ${POSTGRES_USER_LOGGER}
|
||||||
PG_PASS: ${POSTGRES_PASSWORD_LOGGER}
|
PG_PASS: ${POSTGRES_PASSWORD_LOGGER}
|
||||||
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL}
|
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL:-https://error.xtransit.testinstance.nl/error_log}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
auth_api:
|
auth_api:
|
||||||
@@ -30,7 +30,7 @@ services:
|
|||||||
PG_PASS: ${POSTGRES_PASSWORD_AUTH}
|
PG_PASS: ${POSTGRES_PASSWORD_AUTH}
|
||||||
ADMIN_USER_CODES: ${ADMIN_USER_CODES}
|
ADMIN_USER_CODES: ${ADMIN_USER_CODES}
|
||||||
DEV_BYPASS: ${DEV_BYPASS:-0}
|
DEV_BYPASS: ${DEV_BYPASS:-0}
|
||||||
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL}
|
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL:-https://error.xtransit.testinstance.nl/error_log}
|
||||||
depends_on:
|
depends_on:
|
||||||
- error_log_api
|
- error_log_api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -47,7 +47,7 @@ services:
|
|||||||
PG_DB: ${POSTGRES_DB}
|
PG_DB: ${POSTGRES_DB}
|
||||||
PG_USER: ${POSTGRES_USER_EMPLOYEE}
|
PG_USER: ${POSTGRES_USER_EMPLOYEE}
|
||||||
PG_PASS: ${POSTGRES_PASSWORD_EMPLOYEE}
|
PG_PASS: ${POSTGRES_PASSWORD_EMPLOYEE}
|
||||||
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL}
|
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL:-https://error.xtransit.testinstance.nl/error_log}
|
||||||
depends_on:
|
depends_on:
|
||||||
- error_log_api
|
- error_log_api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -66,7 +66,7 @@ services:
|
|||||||
PG_USER: ${POSTGRES_USER_TRANSIT}
|
PG_USER: ${POSTGRES_USER_TRANSIT}
|
||||||
PG_PASS: ${POSTGRES_PASSWORD_TRANSIT}
|
PG_PASS: ${POSTGRES_PASSWORD_TRANSIT}
|
||||||
DEV_MOCK: ${DEV_MOCK:-0}
|
DEV_MOCK: ${DEV_MOCK:-0}
|
||||||
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL}
|
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL:-https://error.xtransit.testinstance.nl/error_log}
|
||||||
depends_on:
|
depends_on:
|
||||||
- error_log_api
|
- error_log_api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -79,7 +79,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
AUTH_API: ${AUTH_API}
|
AUTH_API: ${AUTH_API}
|
||||||
TRANSIT_API: ${TRANSIT_API}
|
TRANSIT_API: ${TRANSIT_API}
|
||||||
ERROR_LOG_API: ${ERROR_LOG_API}
|
ERROR_LOG_API: ${ERROR_LOG_API:-https://error.xtransit.testinstance.nl}
|
||||||
depends_on:
|
depends_on:
|
||||||
- auth_api
|
- auth_api
|
||||||
- transit_api
|
- transit_api
|
||||||
|
|||||||
+81
-58
@@ -1,79 +1,102 @@
|
|||||||
const express = require('express');
|
const express = require("express");
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require("bcryptjs");
|
||||||
const db = require('./db');
|
const db = require("./db");
|
||||||
require('dotenv').config();
|
require("dotenv").config();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
const ERROR_LOG_API_URL = process.env.ERROR_LOG_API_URL || 'http://localhost:5003/error_log';
|
const ERROR_LOG_API_URL =
|
||||||
|
process.env.ERROR_LOG_API_URL ||
|
||||||
|
"https://error.xtransit.testinstance.nl/error_log";
|
||||||
|
|
||||||
function friendlyErrorMessage(statusCode) {
|
function friendlyErrorMessage(statusCode) {
|
||||||
if (statusCode === 400) return 'Controleer je invoer en probeer opnieuw.';
|
if (statusCode === 400) return "Controleer je invoer en probeer opnieuw.";
|
||||||
if (statusCode === 401) return 'Je sessie is verlopen. Log opnieuw in.';
|
if (statusCode === 401) return "Je sessie is verlopen. Log opnieuw in.";
|
||||||
if (statusCode === 403) return 'Je hebt hier geen toegang toe.';
|
if (statusCode === 403) return "Je hebt hier geen toegang toe.";
|
||||||
if (statusCode === 404) return 'Niet gevonden.';
|
if (statusCode === 404) return "Niet gevonden.";
|
||||||
return 'Er ging iets mis. Probeer het later opnieuw.';
|
return "Er ging iets mis. Probeer het later opnieuw.";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reportError({ partOfService, error }) {
|
async function reportError({ partofservice, error }) {
|
||||||
try {
|
try {
|
||||||
await fetch(ERROR_LOG_API_URL, {
|
await fetch(ERROR_LOG_API_URL, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
service: 'employee_api',
|
service: "employee_api",
|
||||||
partOfService,
|
partofservice,
|
||||||
error,
|
error,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const originalJson = res.json.bind(res);
|
const originalJson = res.json.bind(res);
|
||||||
res.json = (body) => {
|
res.json = (body) => {
|
||||||
if (res.statusCode >= 400 && body && typeof body === 'object') {
|
if (res.statusCode >= 400 && body && typeof body === "object") {
|
||||||
const rawError = body.error || body.message || body.details || `HTTP ${res.statusCode}`;
|
const rawError =
|
||||||
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: rawError });
|
body.error || body.message || body.details || `HTTP ${res.statusCode}`;
|
||||||
return originalJson({ error: friendlyErrorMessage(res.statusCode) });
|
void reportError({
|
||||||
}
|
partofservice: `${req.method} ${req.originalUrl}`,
|
||||||
return originalJson(body);
|
error: rawError,
|
||||||
};
|
});
|
||||||
|
return originalJson({ error: friendlyErrorMessage(res.statusCode) });
|
||||||
|
}
|
||||||
|
return originalJson(body);
|
||||||
|
};
|
||||||
|
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Employee onboarding
|
// Employee onboarding
|
||||||
app.post('/createemployee', async (req, res) => {
|
app.post("/createemployee", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const {firstname, lastname, job, password} = req.body;
|
const { firstname, lastname, job, password } = req.body;
|
||||||
if (!firstname || !lastname || !job || !password) return res.status(400).json({error: 'Firstname, Lastname, Job title and password are required'});
|
if (!firstname || !lastname || !job || !password)
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Firstname, Lastname, Job title and password are required",
|
||||||
|
});
|
||||||
|
|
||||||
const resultCreateEmployee = await db.query('INSERT INTO workers.employees (firstname, lastname, job) VALUES ($1, $2, $3) RETURNING id', [firstname, lastname, job]);
|
const resultCreateEmployee = await db.query(
|
||||||
const newEmployee = resultCreateEmployee.rows[0];
|
"INSERT INTO workers.employees (firstname, lastname, job) VALUES ($1, $2, $3) RETURNING id",
|
||||||
const encryptedpassword = await bcrypt.hash(password, 12);
|
[firstname, lastname, job],
|
||||||
const resultCreateUser = await db.query('INSERT INTO workers.users (employee, password) VALUES ($1, $2) RETURNING usercode', [newEmployee.id, encryptedpassword]);
|
);
|
||||||
const newUser = resultCreateUser.rows[0];
|
const newEmployee = resultCreateEmployee.rows[0];
|
||||||
return res.status(201).json({ employee: firstname, code: newUser.usercoden });
|
const encryptedpassword = await bcrypt.hash(password, 12);
|
||||||
}
|
const resultCreateUser = await db.query(
|
||||||
catch (err) {
|
"INSERT INTO workers.users (employee, password) VALUES ($1, $2) RETURNING usercode",
|
||||||
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(err) });
|
[newEmployee.id, encryptedpassword],
|
||||||
console.error(err);
|
);
|
||||||
res.status(500).json({ error: friendlyErrorMessage(500) });
|
const newUser = resultCreateUser.rows[0];
|
||||||
|
return res
|
||||||
|
.status(201)
|
||||||
|
.json({ employee: firstname, code: newUser.usercoden });
|
||||||
|
} catch (err) {
|
||||||
|
void reportError({
|
||||||
|
partofservice: `${req.method} ${req.originalUrl}`,
|
||||||
|
error: err?.stack || err?.message || String(err),
|
||||||
|
});
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: friendlyErrorMessage(500) });
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
app.get('/health', async (_req, res, _next) => {
|
app.get("/health", async (_req, res, _next) => {
|
||||||
try {
|
try {
|
||||||
return res.status(200).json({endpoint: 'reachable'})
|
return res.status(200).json({ endpoint: "reachable" });
|
||||||
}
|
} catch (err) {
|
||||||
catch (err) {
|
void reportError({
|
||||||
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(err) });
|
partofservice: `${req.method} ${req.originalUrl}`,
|
||||||
console.error(err);
|
error: err?.stack || err?.message || String(err),
|
||||||
res.status(500).json({ endpoint: 'unreachable', error: friendlyErrorMessage(500) })
|
});
|
||||||
}
|
console.error(err);
|
||||||
})
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ endpoint: "unreachable", error: friendlyErrorMessage(500) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const PORT = process.env.PORT || 4000;
|
const PORT = process.env.PORT || 4000;
|
||||||
app.listen(PORT, () => console.log(`Auth API listening on ${PORT}`));
|
app.listen(PORT, () => console.log(`Auth API listening on ${PORT}`));
|
||||||
|
|||||||
+51
-39
@@ -1,29 +1,29 @@
|
|||||||
const express = require('express');
|
const express = require("express");
|
||||||
const cors = require('cors');
|
const cors = require("cors");
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require("jsonwebtoken");
|
||||||
require('dotenv').config();
|
require("dotenv").config();
|
||||||
|
|
||||||
const db = require('./db');
|
const db = require("./db");
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json({ limit: '64kb' }));
|
app.use(express.json({ limit: "64kb" }));
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT || 5003);
|
const PORT = Number(process.env.PORT || 5003);
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || '';
|
const JWT_SECRET = process.env.JWT_SECRET || "";
|
||||||
// Admin verification is done by `auth_api` and carried in the JWT as `isAdmin`.
|
// Admin verification is done by `auth_api` and carried in the JWT as `isAdmin`.
|
||||||
|
|
||||||
function toFriendlyError(statusCode) {
|
function toFriendlyError(statusCode) {
|
||||||
if (statusCode === 400) return 'Controleer je invoer en probeer opnieuw.';
|
if (statusCode === 400) return "Controleer je invoer en probeer opnieuw.";
|
||||||
if (statusCode === 401) return 'Je sessie is verlopen. Log opnieuw in.';
|
if (statusCode === 401) return "Je sessie is verlopen. Log opnieuw in.";
|
||||||
if (statusCode === 403) return 'Je hebt hier geen toegang toe.';
|
if (statusCode === 403) return "Je hebt hier geen toegang toe.";
|
||||||
if (statusCode === 404) return 'Niet gevonden.';
|
if (statusCode === 404) return "Niet gevonden.";
|
||||||
return 'Er ging iets mis. Probeer het later opnieuw.';
|
return "Er ging iets mis. Probeer het later opnieuw.";
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeErrorText(value) {
|
function normalizeErrorText(value) {
|
||||||
if (!value) return 'Unknown error';
|
if (!value) return "Unknown error";
|
||||||
if (typeof value === 'string') return value;
|
if (typeof value === "string") return value;
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value);
|
return JSON.stringify(value);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -31,20 +31,28 @@ function normalizeErrorText(value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function insertErrorLog({ service, partOfService, error }) {
|
async function insertErrorLog({
|
||||||
const serviceName = String(service || 'unknown_service').slice(0, 120);
|
service,
|
||||||
const part = partOfService ? String(partOfService).slice(0, 240) : null;
|
partofservice,
|
||||||
|
partOfService,
|
||||||
|
error,
|
||||||
|
}) {
|
||||||
|
const serviceName = String(service || "unknown_service").slice(0, 120);
|
||||||
|
const part =
|
||||||
|
partofservice || partOfService
|
||||||
|
? String(partofservice || partOfService).slice(0, 240)
|
||||||
|
: null;
|
||||||
const errorText = String(normalizeErrorText(error)).slice(0, 8000);
|
const errorText = String(normalizeErrorText(error)).slice(0, 8000);
|
||||||
|
|
||||||
await db.query(
|
await db.query(
|
||||||
'INSERT INTO data.errors ("service", "partOfService", "error") VALUES ($1, $2, $3)',
|
"INSERT INTO data.errors (service, partofservice, error) VALUES ($1, $2, $3)",
|
||||||
[serviceName, part, errorText]
|
[serviceName, part, errorText],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function verifyAdmin(req, res, next) {
|
function verifyAdmin(req, res, next) {
|
||||||
const auth = req.headers.authorization;
|
const auth = req.headers.authorization;
|
||||||
if (!auth || !auth.startsWith('Bearer ')) {
|
if (!auth || !auth.startsWith("Bearer ")) {
|
||||||
return res.status(401).json({ error: toFriendlyError(401) });
|
return res.status(401).json({ error: toFriendlyError(401) });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,33 +69,37 @@ function verifyAdmin(req, res, next) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/health', async (_req, res) => {
|
app.get("/health", async (_req, res) => {
|
||||||
res.json({ status: 'ok', service: 'error_log_api' });
|
res.json({ status: "ok", service: "error_log_api" });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/error_log', async (req, res) => {
|
app.post("/error_log", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const service = req.body?.service;
|
const service = req.body?.service;
|
||||||
const partOfService = req.body?.partOfService || null;
|
const partofservice =
|
||||||
|
req.body?.partofservice || req.body?.partOfService || null;
|
||||||
const error = req.body?.error;
|
const error = req.body?.error;
|
||||||
|
|
||||||
if (!service || !error) {
|
if (!service || !error) {
|
||||||
return res.status(400).json({ error: toFriendlyError(400) });
|
return res.status(400).json({ error: toFriendlyError(400) });
|
||||||
}
|
}
|
||||||
|
|
||||||
await insertErrorLog({ service, partOfService, error });
|
await insertErrorLog({ service, partofservice, error });
|
||||||
return res.status(201).json({ ok: true });
|
return res.status(201).json({ ok: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('POST /error_log failed:', err);
|
console.error("POST /error_log failed:", err);
|
||||||
return res.status(500).json({ error: toFriendlyError(500) });
|
return res.status(500).json({ error: toFriendlyError(500) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/error_log', verifyAdmin, async (req, res) => {
|
app.get("/error_log", verifyAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const limit = Math.min(Number(req.query.limit || 100), 500);
|
const limit = Math.min(Number(req.query.limit || 100), 500);
|
||||||
const service = req.query.service ? String(req.query.service) : null;
|
const service = req.query.service ? String(req.query.service) : null;
|
||||||
const partOfService = req.query.partOfService ? String(req.query.partOfService) : null;
|
const partofservice =
|
||||||
|
req.query.partofservice || req.query.partOfService
|
||||||
|
? String(req.query.partofservice || req.query.partOfService)
|
||||||
|
: null;
|
||||||
|
|
||||||
const where = [];
|
const where = [];
|
||||||
const params = [];
|
const params = [];
|
||||||
@@ -96,25 +108,25 @@ app.get('/error_log', verifyAdmin, async (req, res) => {
|
|||||||
params.push(service);
|
params.push(service);
|
||||||
where.push(`"service" = $${params.length}`);
|
where.push(`"service" = $${params.length}`);
|
||||||
}
|
}
|
||||||
if (partOfService) {
|
if (partofservice) {
|
||||||
params.push(partOfService);
|
params.push(partofservice);
|
||||||
where.push(`"partOfService" ILIKE $${params.length}`);
|
where.push(`partofservice ILIKE $${params.length}`);
|
||||||
params[params.length - 1] = `%${partOfService}%`;
|
params[params.length - 1] = `%${partofservice}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
params.push(limit);
|
params.push(limit);
|
||||||
const rows = await db.query(
|
const rows = await db.query(
|
||||||
`SELECT id, "date", "service", "partOfService", "error"
|
`SELECT id, date, service, partofservice, error
|
||||||
FROM data.errors
|
FROM data.errors
|
||||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
|
||||||
ORDER BY "date" DESC, id DESC
|
ORDER BY "date" DESC, id DESC
|
||||||
LIMIT $${params.length}`,
|
LIMIT $${params.length}`,
|
||||||
params
|
params,
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.json({ errors: rows.rows });
|
return res.json({ errors: rows.rows });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('GET /error_log failed:', err);
|
console.error("GET /error_log failed:", err);
|
||||||
return res.status(500).json({ error: toFriendlyError(500) });
|
return res.status(500).json({ error: toFriendlyError(500) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -122,12 +134,12 @@ app.get('/error_log', verifyAdmin, async (req, res) => {
|
|||||||
app.use(async (err, req, res, _next) => {
|
app.use(async (err, req, res, _next) => {
|
||||||
try {
|
try {
|
||||||
await insertErrorLog({
|
await insertErrorLog({
|
||||||
service: 'error_log_api',
|
service: "error_log_api",
|
||||||
partOfService: `${req.method} ${req.originalUrl}`,
|
partofservice: `${req.method} ${req.originalUrl}`,
|
||||||
error: err?.stack || err?.message || String(err),
|
error: err?.stack || err?.message || String(err),
|
||||||
});
|
});
|
||||||
} catch (logErr) {
|
} catch (logErr) {
|
||||||
console.error('Failed to log internal error:', logErr);
|
console.error("Failed to log internal error:", logErr);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.headersSent) return;
|
if (res.headersSent) return;
|
||||||
|
|||||||
+2
-2
@@ -14,8 +14,8 @@ case $COMMAND in
|
|||||||
echo ""
|
echo ""
|
||||||
echo "📋 Service URLs:"
|
echo "📋 Service URLs:"
|
||||||
echo " Admin UI: http://localhost:5174"
|
echo " Admin UI: http://localhost:5174"
|
||||||
echo " Auth API: http://localhost:5000"
|
echo " Auth API: https://auth.xtransit.testinstance.nl"
|
||||||
echo " Transit API: http://localhost:5001"
|
echo " Transit API: https://transit.xtransit.testinstance.nl"
|
||||||
echo " Employee API: http://localhost:4000"
|
echo " Employee API: http://localhost:4000"
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ Note: `ADMIN_USER_CODES` has been moved to the `auth_api` service. Configure adm
|
|||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
API draait op `http://localhost:5001`
|
API draait op `https://transit.xtransit.testinstance.nl`
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ De legacy endpoint **GET** `/schedules` blijft beschikbaar voor bestaande client
|
|||||||
Voorbeeld:
|
Voorbeeld:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&busStopId=3"
|
curl "https://transit.xtransit.testinstance.nl/schedules/overview?date=2026-05-06&chauffeurId=12&busStopId=3"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Navigation
|
### Navigation
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ app.use(express.json());
|
|||||||
app.use(cors());
|
app.use(cors());
|
||||||
|
|
||||||
const ERROR_LOG_API_URL =
|
const ERROR_LOG_API_URL =
|
||||||
process.env.ERROR_LOG_API_URL || "http://localhost:5003/error_log";
|
process.env.ERROR_LOG_API_URL ||
|
||||||
|
"https://error.xtransit.testinstance.nl/error_log";
|
||||||
|
|
||||||
function friendlyErrorMessage(statusCode) {
|
function friendlyErrorMessage(statusCode) {
|
||||||
if (statusCode === 400) return "Controleer je invoer en probeer opnieuw.";
|
if (statusCode === 400) return "Controleer je invoer en probeer opnieuw.";
|
||||||
@@ -20,14 +21,14 @@ function friendlyErrorMessage(statusCode) {
|
|||||||
return "Er ging iets mis. Probeer het later opnieuw.";
|
return "Er ging iets mis. Probeer het later opnieuw.";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reportError({ partOfService, error }) {
|
async function reportError({ partofservice, error }) {
|
||||||
try {
|
try {
|
||||||
await fetch(ERROR_LOG_API_URL, {
|
await fetch(ERROR_LOG_API_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
service: "transit_api",
|
service: "transit_api",
|
||||||
partOfService,
|
partofservice,
|
||||||
error,
|
error,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -41,7 +42,7 @@ app.use((req, res, next) => {
|
|||||||
const rawError =
|
const rawError =
|
||||||
body.error || body.message || body.details || `HTTP ${res.statusCode}`;
|
body.error || body.message || body.details || `HTTP ${res.statusCode}`;
|
||||||
void reportError({
|
void reportError({
|
||||||
partOfService: `${req.method} ${req.originalUrl}`,
|
partofservice: `${req.method} ${req.originalUrl}`,
|
||||||
error: rawError,
|
error: rawError,
|
||||||
});
|
});
|
||||||
return originalJson({ error: friendlyErrorMessage(res.statusCode) });
|
return originalJson({ error: friendlyErrorMessage(res.statusCode) });
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
Native Win32 driver client targeting Windows XP-era systems.
|
Native Win32 driver client targeting Windows XP-era systems.
|
||||||
|
|
||||||
The client authenticates with `auth_api` at `192.168.68.66:5000`, then requests
|
The client authenticates with `https://auth.xtransit.testinstance.nl`, then requests
|
||||||
assigned weekly schedule templates from `transit_api` at
|
assigned weekly schedule templates from
|
||||||
`192.168.68.66:5001/driver/schedules/daily`.
|
`https://transit.xtransit.testinstance.nl/driver/schedules/daily`.
|
||||||
|
|
||||||
Schedules are assigned through `transit.shifts`; the client does not expect a
|
Schedules are assigned through `transit.shifts`; the client does not expect a
|
||||||
`driver_id`, `bus_id`, or `weekdays` column on `transit.schedules`.
|
`driver_id`, `bus_id`, or `weekdays` column on `transit.schedules`.
|
||||||
|
|||||||
Binary file not shown.
@@ -25,7 +25,7 @@
|
|||||||
#define ID_ROUTES_LABEL 116
|
#define ID_ROUTES_LABEL 116
|
||||||
|
|
||||||
#define MAX_BUF 65535
|
#define MAX_BUF 65535
|
||||||
#define API_HOST "192.168.68.66"
|
#define API_HOST "auth.xtransit.testinstance.nl"
|
||||||
#define AUTH_PORT 5000
|
#define AUTH_PORT 5000
|
||||||
#define TRANSIT_PORT 5001
|
#define TRANSIT_PORT 5001
|
||||||
|
|
||||||
@@ -538,6 +538,7 @@ static int HttpRequestText(const char *method,
|
|||||||
const char *src;
|
const char *src;
|
||||||
const char *slash;
|
const char *slash;
|
||||||
const char *colon;
|
const char *colon;
|
||||||
|
int secure = 0;
|
||||||
int port = 80;
|
int port = 80;
|
||||||
DWORD flags = 0;
|
DWORD flags = 0;
|
||||||
DWORD status = 0;
|
DWORD status = 0;
|
||||||
@@ -554,6 +555,7 @@ static int HttpRequestText(const char *method,
|
|||||||
src += 7;
|
src += 7;
|
||||||
} else if (strncmp(src, "https://", 8) == 0) {
|
} else if (strncmp(src, "https://", 8) == 0) {
|
||||||
src += 8;
|
src += 8;
|
||||||
|
secure = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
host[0] = '\0';
|
host[0] = '\0';
|
||||||
@@ -596,7 +598,7 @@ static int HttpRequestText(const char *method,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (host[0] == '\0') {
|
if (host[0] == '\0') {
|
||||||
snprintf(host, sizeof(host), "192.168.68.66");
|
snprintf(host, sizeof(host), "%s", API_HOST);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (port == 0) {
|
if (port == 0) {
|
||||||
@@ -633,7 +635,7 @@ static int HttpRequestText(const char *method,
|
|||||||
NULL,
|
NULL,
|
||||||
WINHTTP_NO_REFERER,
|
WINHTTP_NO_REFERER,
|
||||||
WINHTTP_DEFAULT_ACCEPT_TYPES,
|
WINHTTP_DEFAULT_ACCEPT_TYPES,
|
||||||
0);
|
secure ? WINHTTP_FLAG_SECURE : 0);
|
||||||
if (!hRequest) {
|
if (!hRequest) {
|
||||||
HttpErrorMessage("Unable to open request", GetLastError());
|
HttpErrorMessage("Unable to open request", GetLastError());
|
||||||
goto cleanup;
|
goto cleanup;
|
||||||
@@ -747,7 +749,7 @@ static void PerformLogin(HWND hwnd, APPSTATE *state)
|
|||||||
userCode, password);
|
userCode, password);
|
||||||
|
|
||||||
SetStatusText(hwnd, "Logging in...");
|
SetStatusText(hwnd, "Logging in...");
|
||||||
if (!HttpRequestText("POST", "http://192.168.68.66:5000/login", payload, NULL, response, sizeof(response))) {
|
if (!HttpRequestText("POST", "https://auth.xtransit.testinstance.nl/login", payload, NULL, response, sizeof(response))) {
|
||||||
SetOutputText(hwnd, "Driver service unreachable. Please try again.");
|
SetOutputText(hwnd, "Driver service unreachable. Please try again.");
|
||||||
SetStatusText(hwnd, "Login failed");
|
SetStatusText(hwnd, "Login failed");
|
||||||
return;
|
return;
|
||||||
@@ -784,7 +786,7 @@ static void PerformRefresh(HWND hwnd, APPSTATE *state)
|
|||||||
if (date[0] == '\0') {
|
if (date[0] == '\0') {
|
||||||
snprintf(date, sizeof(date), "2026-09-17");
|
snprintf(date, sizeof(date), "2026-09-17");
|
||||||
}
|
}
|
||||||
snprintf(scheduleUrl, sizeof(scheduleUrl), "http://192.168.68.66:5001/driver/schedules/daily?date=%s", date);
|
snprintf(scheduleUrl, sizeof(scheduleUrl), "https://transit.xtransit.testinstance.nl/driver/schedules/daily?date=%s", date);
|
||||||
SetStatusText(hwnd, "Loading schedule...");
|
SetStatusText(hwnd, "Loading schedule...");
|
||||||
|
|
||||||
if (!HttpRequestText("GET", scheduleUrl, NULL, authHeader, response, sizeof(response))) {
|
if (!HttpRequestText("GET", scheduleUrl, NULL, authHeader, response, sizeof(response))) {
|
||||||
@@ -825,7 +827,7 @@ static void PerformLoadStops(HWND hwnd, APPSTATE *state)
|
|||||||
}
|
}
|
||||||
|
|
||||||
snprintf(authHeader, sizeof(authHeader), "Authorization: Bearer %s\r\n", state->token);
|
snprintf(authHeader, sizeof(authHeader), "Authorization: Bearer %s\r\n", state->token);
|
||||||
snprintf(stopsUrl, sizeof(stopsUrl), "http://192.168.68.66:5001/bus-lines/%d/stops", lineId);
|
snprintf(stopsUrl, sizeof(stopsUrl), "https://transit.xtransit.testinstance.nl/bus-lines/%d/stops", lineId);
|
||||||
SetStatusText(hwnd, "Loading stop details...");
|
SetStatusText(hwnd, "Loading stop details...");
|
||||||
|
|
||||||
if (!HttpRequestText("GET", stopsUrl, NULL, authHeader, response, sizeof(response))) {
|
if (!HttpRequestText("GET", stopsUrl, NULL, authHeader, response, sizeof(response))) {
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user