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

This commit is contained in:
Sven laptop
2026-09-18 19:26:05 +02:00
parent 3f0a02aa04
commit 1154d1d658
21 changed files with 609 additions and 380 deletions
+69 -50
View File
@@ -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 rawValue = value || ''
if (!rawValue) return fallbackPath
if (/^https?:\/\//i.test(rawValue) || rawValue.startsWith('//') || rawValue.startsWith('/')) {
return rawValue
const rawValue = value || "";
if (!rawValue) return fallbackPath;
if (
/^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 TRANSIT_API = normalizeApiBaseUrl(RUNTIME.TRANSIT_API || import.meta.env.VITE_TRANSIT_API, 'http://localhost:5001')
const ERROR_LOG_API = normalizeApiBaseUrl(RUNTIME.ERROR_LOG_API || import.meta.env.VITE_ERROR_LOG_API, 'http://localhost:5003')
const AUTH_API = normalizeApiBaseUrl(
RUNTIME.AUTH_API || import.meta.env.VITE_AUTH_API,
"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 {
await fetch(`${ERROR_LOG_API}/error_log`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
service,
partOfService,
error: typeof error === 'string' ? error : JSON.stringify(error),
partofservice,
error: typeof error === "string" ? error : JSON.stringify(error),
}),
})
});
} catch (e) {
// ignore logging failures
}
}
};
export const authClient = axios.create({ baseURL: AUTH_API })
export const authClient = axios.create({ baseURL: AUTH_API });
authClient.interceptors.response.use(
response => response,
error => {
(response) => response,
(error) => {
void reportClientError({
service: 'admin_ui',
partOfService: 'authClient',
error: error?.response?.data?.error || error?.message || 'authClient_error',
})
return Promise.reject(error)
}
)
service: "admin_ui",
partofservice: "authClient",
error:
error?.response?.data?.error || error?.message || "authClient_error",
});
return Promise.reject(error);
},
);
export const transitClient = (token) => {
const client = axios.create({
baseURL: TRANSIT_API,
headers: { Authorization: `Bearer ${token}` }
})
headers: { Authorization: `Bearer ${token}` },
});
// Auto-logout on unauthorized responses to avoid delayed failures
client.interceptors.response.use(
response => response,
error => {
(response) => response,
(error) => {
try {
const status = error?.response?.status
const status = error?.response?.status;
if (status === 401 || status === 403) {
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem("token");
localStorage.removeItem("user");
// reload to let App show the Login screen
if (typeof window !== 'undefined') window.location.reload()
if (typeof window !== "undefined") window.location.reload();
}
void reportClientError({
service: 'admin_ui',
partOfService: 'transitClient',
error: error?.response?.data?.error || error?.message || 'transitClient_error',
})
service: "admin_ui",
partofservice: "transitClient",
error:
error?.response?.data?.error ||
error?.message ||
"transitClient_error",
});
} catch (e) {
// ignore
}
return Promise.reject(error)
}
)
return Promise.reject(error);
},
);
return client
}
return client;
};
export const errorLogClient = (token) => axios.create({
baseURL: ERROR_LOG_API,
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
export const errorLogClient = (token) =>
axios.create({
baseURL: ERROR_LOG_API,
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
export { reportClientError }
export { reportClientError };
+45 -31
View File
@@ -1,42 +1,44 @@
import React, { useEffect, useMemo, useState } from 'react'
import { errorLogClient } from '../api'
import React, { useEffect, useMemo, useState } from "react";
import { errorLogClient } from "../api";
const formatDate = (value) => {
if (!value) return '—'
if (!value) return "—";
try {
return new Date(value).toLocaleString()
return new Date(value).toLocaleString();
} catch (_) {
return String(value)
return String(value);
}
}
};
export default function ErrorLogViewer({ token }) {
const client = useMemo(() => errorLogClient(token), [token])
const [logs, setLogs] = useState([])
const [service, setService] = useState('')
const [partOfService, setPartOfService] = useState('')
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState(null)
const client = useMemo(() => errorLogClient(token), [token]);
const [logs, setLogs] = useState([]);
const [service, setService] = useState("");
const [partofservice, setPartofservice] = useState("");
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState(null);
const loadLogs = async () => {
setLoading(true)
setMessage(null)
setLoading(true);
setMessage(null);
try {
const params = { limit: 100 }
if (service) params.service = service
if (partOfService) params.partOfService = partOfService
const res = await client.get('/error_log', { params })
setLogs(res.data.errors || [])
const params = { limit: 100 };
if (service) params.service = service;
if (partofservice) params.partofservice = partofservice;
const res = await client.get("/error_log", { params });
setLogs(res.data.errors || []);
} 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 {
setLoading(false)
setLoading(false);
}
}
};
useEffect(() => {
loadLogs()
}, [service, partOfService])
loadLogs();
}, [service, partofservice]);
return (
<section className="card">
@@ -46,29 +48,41 @@ export default function ErrorLogViewer({ token }) {
<p>Laatste foutmeldingen uit de centrale logging service.</p>
</div>
<button className="secondary" onClick={loadLogs} disabled={loading}>
{loading ? 'Laden...' : 'Ververs'}
{loading ? "Laden..." : "Ververs"}
</button>
</div>
<div className="form-row">
<input 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)} />
<input
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>
{message && <div className="message">{message}</div>}
<div className="list-grid">
{logs.map(log => (
{logs.map((log) => (
<article className="mini-card" key={log.id}>
<strong>{log.service}</strong>
<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>
</article>
))}
</div>
{!logs.length && !message && <div className="muted">Nog geen foutmeldingen gevonden.</div>}
{!logs.length && !message && (
<div className="muted">Nog geen foutmeldingen gevonden.</div>
)}
</section>
)
);
}
+188 -85
View File
@@ -1,72 +1,85 @@
import React, { useEffect, useState } from 'react'
import React, { useEffect, useState } from "react";
export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }) {
const [schedules, setSchedules] = useState([])
const [summary, setSummary] = useState(null)
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0])
const [selectedLineId, setSelectedLineId] = useState('')
const [lines, setLines] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
export default function TimetableViewer({
apiBaseUrl = "https://transit.xtransit.testinstance.nl",
}) {
const [schedules, setSchedules] = useState([]);
const [summary, setSummary] = useState(null);
const [selectedDate, setSelectedDate] = useState(
new Date().toISOString().split("T")[0],
);
const [selectedLineId, setSelectedLineId] = useState("");
const [lines, setLines] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// Fetch available bus lines
useEffect(() => {
const fetchLines = async () => {
try {
const res = await fetch(`${apiBaseUrl}/admin/bus-lines`)
const data = await res.json()
setLines(data.busLines || [])
const res = await fetch(`${apiBaseUrl}/admin/bus-lines`);
const data = await res.json();
setLines(data.busLines || []);
} catch (err) {
console.error('Failed to load lines:', err)
console.error("Failed to load lines:", err);
}
}
fetchLines()
}, [apiBaseUrl])
};
fetchLines();
}, [apiBaseUrl]);
// Fetch schedules based on date and line filter
useEffect(() => {
const fetchSchedules = async () => {
setLoading(true)
setError(null)
setLoading(true);
setError(null);
try {
let url = `${apiBaseUrl}/schedules/overview?date=${selectedDate}`
let url = `${apiBaseUrl}/schedules/overview?date=${selectedDate}`;
if (selectedLineId) {
url += `&lineId=${selectedLineId}`
url += `&lineId=${selectedLineId}`;
}
const res = await fetch(url)
const data = await res.json()
setSchedules(data.schedules || [])
setSummary(data.summary || null)
const res = await fetch(url);
const data = await res.json();
setSchedules(data.schedules || []);
setSummary(data.summary || null);
} catch (err) {
setError('Failed to load schedules: ' + err.message)
setError("Failed to load schedules: " + err.message);
} finally {
setLoading(false)
setLoading(false);
}
}
fetchSchedules()
}, [selectedDate, selectedLineId, apiBaseUrl])
};
fetchSchedules();
}, [selectedDate, selectedLineId, apiBaseUrl]);
const timeSlots = generateTimeSlots()
const schedulesBySlot = groupSchedulesByTimeSlot(schedules, timeSlots)
const timeSlots = generateTimeSlots();
const schedulesBySlot = groupSchedulesByTimeSlot(schedules, timeSlots);
return (
<div style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto' }}>
<div style={{ padding: "20px", maxWidth: "1200px", margin: "0 auto" }}>
<h1>Public Transit Timetable</h1>
<div style={{ marginBottom: '20px', display: 'flex', gap: '10px' }}>
<div style={{ marginBottom: "20px", display: "flex", gap: "10px" }}>
<input
type="date"
value={selectedDate}
onChange={e => setSelectedDate(e.target.value)}
style={{ padding: '8px', borderRadius: '6px', border: '1px solid #ccc' }}
onChange={(e) => setSelectedDate(e.target.value)}
style={{
padding: "8px",
borderRadius: "6px",
border: "1px solid #ccc",
}}
/>
<select
value={selectedLineId}
onChange={e => setSelectedLineId(e.target.value)}
style={{ padding: '8px', borderRadius: '6px', border: '1px solid #ccc', minWidth: '200px' }}
onChange={(e) => setSelectedLineId(e.target.value)}
style={{
padding: "8px",
borderRadius: "6px",
border: "1px solid #ccc",
minWidth: "200px",
}}
>
<option value="">All lines</option>
{lines.map(line => (
{lines.map((line) => (
<option key={line.id} value={line.id}>
Line {line.lineNumber} ({line.startStop} → {line.endStop})
</option>
@@ -75,101 +88,191 @@ export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }
</div>
{summary && (
<div style={{ marginBottom: '16px', display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
<span style={{ padding: '6px 10px', borderRadius: '999px', backgroundColor: '#eef2ff', color: '#3730a3' }}>
<div
style={{
marginBottom: "16px",
display: "flex",
gap: "12px",
flexWrap: "wrap",
}}
>
<span
style={{
padding: "6px 10px",
borderRadius: "999px",
backgroundColor: "#eef2ff",
color: "#3730a3",
}}
>
Total: {summary.total}
</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}
</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}
</span>
</div>
)}
{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' }}>
<thead style={{ backgroundColor: '#f1f5f9' }}>
<table
style={{ width: "100%", borderCollapse: "collapse", marginTop: "20px" }}
>
<thead style={{ backgroundColor: "#f1f5f9" }}>
<tr>
<th style={{ padding: '12px', textAlign: 'left', 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>
<th
style={{
padding: "12px",
textAlign: "left",
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>
</thead>
<tbody>
{timeSlots.map((slot, idx) => {
const slotSchedules = schedulesBySlot[slot] || []
if (slotSchedules.length === 0) return null
const slotSchedules = schedulesBySlot[slot] || [];
if (slotSchedules.length === 0) return null;
return slotSchedules.map((schedule, sidx) => (
<tr key={`${idx}-${sidx}`} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '12px' }}>
{sidx === 0 ? slot : ''}
</td>
<td style={{ padding: '12px', fontWeight: 'bold', color: '#4f46e5' }}>
<tr
key={`${idx}-${sidx}`}
style={{ borderBottom: "1px solid #eee" }}
>
<td style={{ padding: "12px" }}>{sidx === 0 ? slot : ""}</td>
<td
style={{
padding: "12px",
fontWeight: "bold",
color: "#4f46e5",
}}
>
Line {schedule.lineNumber}
</td>
<td style={{ padding: '12px' }}>
<td style={{ padding: "12px" }}>
{schedule.busName} ({schedule.licensePlate})
</td>
<td style={{ padding: '12px' }}>
<td style={{ padding: "12px" }}>
<span
style={{
padding: '4px 8px',
borderRadius: '4px',
backgroundColor: schedule.status === 'in_progress' ? '#dbeafe' : schedule.status === 'completed' ? '#dcfce7' : '#f3f4f6',
color: schedule.status === 'in_progress' ? '#1d4ed8' : schedule.status === 'completed' ? '#166534' : '#374151',
fontSize: '0.9rem',
padding: "4px 8px",
borderRadius: "4px",
backgroundColor:
schedule.status === "in_progress"
? "#dbeafe"
: schedule.status === "completed"
? "#dcfce7"
: "#f3f4f6",
color:
schedule.status === "in_progress"
? "#1d4ed8"
: schedule.status === "completed"
? "#166534"
: "#374151",
fontSize: "0.9rem",
}}
>
{schedule.status}
</span>
</td>
<td style={{ padding: '12px' }}>
{typeof schedule.currentDelayMinutes === 'number' && schedule.currentDelayMinutes !== null
? `${schedule.currentDelayMinutes > 0 ? '+' : ''}${schedule.currentDelayMinutes} min`
: '—'}
<td style={{ padding: "12px" }}>
{typeof schedule.currentDelayMinutes === "number" &&
schedule.currentDelayMinutes !== null
? `${schedule.currentDelayMinutes > 0 ? "+" : ""}${schedule.currentDelayMinutes} min`
: "—"}
</td>
</tr>
))
));
})}
</tbody>
</table>
{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.
</p>
)}
</div>
)
);
}
function generateTimeSlots() {
const slots = []
const slots = [];
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) {
const grouped = {}
slots.forEach(slot => {
grouped[slot] = []
})
const grouped = {};
slots.forEach((slot) => {
grouped[slot] = [];
});
schedules.forEach(schedule => {
const startHour = new Date(schedule.startTime).getHours()
const slot = `${String(startHour).padStart(2, '0')}:00-${String((startHour + 1) % 24).padStart(2, '0')}:00`
schedules.forEach((schedule) => {
const startHour = new Date(schedule.startTime).getHours();
const slot = `${String(startHour).padStart(2, "0")}:00-${String((startHour + 1) % 24).padStart(2, "0")}:00`;
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>}
{DAY_GROUPS.map((group) => (
<div key={group.value} style={{ marginTop: "1.5rem" }}>
<h4>{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>
<div className="form-row" style={{ marginTop: "0.75rem" }}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
gap: "1rem",
marginTop: "1.5rem",
alignItems: "start",
}}
>
{DAY_GROUPS.map((group) => (
<section
key={group.value}
style={{
minWidth: 0,
border: "1px solid #dbe3ef",
borderRadius: "8px",
background: "#f8fafc",
padding: "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
value={schedule.driverId || ""}
onChange={(event) =>
assignDriver(schedule.id, event.target.value)
}
style={{ width: "100%", marginTop: "0.75rem" }}
>
<option value="">Unassigned</option>
{drivers.map((driver) => (
@@ -253,16 +271,17 @@ export default function WeeklyScheduleEditor({
type="button"
className="danger"
onClick={() => removeSchedule(schedule.id)}
style={{ width: "100%", marginTop: "0.5rem" }}
>
Delete
</button>
</div>
</article>
))}
</div>
)}
</div>
))}
</article>
))}
</div>
)}
</section>
))}
</div>
</section>
);
}