feat: add employee API integration and request lab component
Build and Push Docker Images / detect-changes (push) Successful in 13s
Build and Push Docker Images / build-auth-api (push) Successful in 1m19s
Build and Push Docker Images / build-employee-api (push) Successful in 1m1s
Build and Push Docker Images / build-transit-api (push) Successful in 59s
Build and Push Docker Images / build-error-log-api (push) Successful in 57s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m14s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m12s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s

- Introduced EMPLOYEE_API endpoint in api.js for employee service.
- Added RequestLab component for testing API routes with a user-friendly interface.
- Updated monitoring_ui documentation to reflect changes in features and authentication.
- Removed unnecessary login functionality from monitoring_ui to maintain unauthenticated access.
- Enhanced styles for new components and improved overall UI consistency.
This commit is contained in:
Sven laptop
2026-10-05 20:03:53 +02:00
parent 30f4ca6701
commit 3c35999a84
9 changed files with 284 additions and 711 deletions
+5 -2
View File
@@ -7,7 +7,8 @@ import BusLinesManager from './components/BusLinesManager'
import FleetManager from './components/FleetManager' import FleetManager from './components/FleetManager'
import ScheduleMaker from './components/ScheduleMaker' import ScheduleMaker from './components/ScheduleMaker'
import ErrorLogViewer from './components/ErrorLogViewer' import ErrorLogViewer from './components/ErrorLogViewer'
import { IconAlert, IconBus, IconCalendar, IconGrid, IconLink, IconLogout, IconMapPin, IconRoute } from './components/icons' import RequestLab from './components/RequestLab'
import { IconAlert, IconBus, IconCalendar, IconGrid, IconLink, IconLogout, IconMapPin, IconRoute, IconTerminal } from './components/icons'
const SECTIONS = [ const SECTIONS = [
{ id: 'overview', label: 'Overzicht', eyebrow: 'Operations', title: 'Dagelijks overzicht', desc: 'Vloot, netwerk en dienstrooster in één oogopslag.', icon: IconGrid }, { id: 'overview', label: 'Overzicht', eyebrow: 'Operations', title: 'Dagelijks overzicht', desc: 'Vloot, netwerk en dienstrooster in één oogopslag.', icon: IconGrid },
@@ -17,13 +18,14 @@ const SECTIONS = [
{ id: 'fleet', label: 'Vloot', eyebrow: 'Vlootbeheer', title: 'Vloot', desc: 'Beheer de voertuigen die deel uitmaken van de vloot.', icon: IconBus }, { id: 'fleet', label: 'Vloot', eyebrow: 'Vlootbeheer', title: 'Vloot', desc: 'Beheer de voertuigen die deel uitmaken van de vloot.', icon: IconBus },
{ id: 'schedules', label: 'Diensten', eyebrow: 'Planning', title: 'Dienstenplanning', desc: 'Plan wekelijkse schema’s en vaste diensten.', icon: IconCalendar }, { id: 'schedules', label: 'Diensten', eyebrow: 'Planning', title: 'Dienstenplanning', desc: 'Plan wekelijkse schema’s en vaste diensten.', icon: IconCalendar },
{ id: 'logs', label: 'Foutmeldingen', eyebrow: 'Systeem', title: 'Centrale foutmeldingen', desc: 'Bekijk fouten die door alle diensten zijn gemeld.', icon: IconAlert }, { id: 'logs', label: 'Foutmeldingen', eyebrow: 'Systeem', title: 'Centrale foutmeldingen', desc: 'Bekijk fouten die door alle diensten zijn gemeld.', icon: IconAlert },
{ id: 'lab', label: 'API-lab', eyebrow: 'Systeem', title: 'API-testomgeving', desc: 'Voer goedgekeurde verzoeken uit en inspecteer live antwoorden.', icon: IconTerminal },
] ]
const NAV_GROUPS = [ const NAV_GROUPS = [
{ label: 'Algemeen', items: ['overview'] }, { label: 'Algemeen', items: ['overview'] },
{ label: 'Netwerkbeheer', items: ['stops', 'lines', 'routes', 'fleet'] }, { label: 'Netwerkbeheer', items: ['stops', 'lines', 'routes', 'fleet'] },
{ label: 'Planning', items: ['schedules'] }, { label: 'Planning', items: ['schedules'] },
{ label: 'Systeem', items: ['logs'] }, { label: 'Systeem', items: ['logs', 'lab'] },
] ]
export default function App() { export default function App() {
@@ -110,6 +112,7 @@ export default function App() {
{section === 'fleet' && <FleetManager token={token} user={user} />} {section === 'fleet' && <FleetManager token={token} user={user} />}
{section === 'schedules' && <ScheduleMaker token={token} user={user} />} {section === 'schedules' && <ScheduleMaker token={token} user={user} />}
{section === 'logs' && <ErrorLogViewer token={token} />} {section === 'logs' && <ErrorLogViewer token={token} />}
{section === 'lab' && <RequestLab token={token} />}
</main> </main>
</div> </div>
</div> </div>
+11
View File
@@ -25,11 +25,22 @@ const TRANSIT_API = normalizeApiBaseUrl(
RUNTIME.TRANSIT_API || import.meta.env.VITE_TRANSIT_API, RUNTIME.TRANSIT_API || import.meta.env.VITE_TRANSIT_API,
"https://transit.xtransit.testinstance.nl", "https://transit.xtransit.testinstance.nl",
); );
const EMPLOYEE_API = normalizeApiBaseUrl(
RUNTIME.EMPLOYEE_API || import.meta.env.VITE_EMPLOYEE_API,
"https://employees.xtransit.testinstance.nl",
);
const ERROR_LOG_API = normalizeApiBaseUrl( const ERROR_LOG_API = normalizeApiBaseUrl(
RUNTIME.ERROR_LOG_API || import.meta.env.VITE_ERROR_LOG_API, RUNTIME.ERROR_LOG_API || import.meta.env.VITE_ERROR_LOG_API,
"https://error.xtransit.testinstance.nl", "https://error.xtransit.testinstance.nl",
); );
export const API_BASE = {
auth: AUTH_API,
employee: EMPLOYEE_API,
transit: TRANSIT_API,
errors: ERROR_LOG_API,
};
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`, {
+164
View File
@@ -0,0 +1,164 @@
import React, { useState } from 'react'
import { API_BASE } from '../api'
import { IconTerminal } from './icons'
const SERVICE_OPTIONS = [
{ key: 'auth', label: 'Auth API' },
{ key: 'employee', label: 'Employee API' },
{ key: 'transit', label: 'Transit API' },
{ key: 'errors', label: 'Error Log API' },
]
const ROUTES = [
{ service: 'transit', method: 'GET', path: '/health', label: 'Transit health' },
{ service: 'transit', method: 'GET', path: '/bus/admin/stops', label: 'Haltes (admin)' },
{ service: 'transit', method: 'GET', path: '/bus/schedules', label: 'Dienstrooster-overzicht' },
{ service: 'transit', method: 'GET', path: '/bus/schedule/daily?date=2026-09-25', label: 'Dagelijkse dienstregeling' },
{ service: 'transit', method: 'GET', path: '/bus/public/schedules/1/progress', label: 'Openbare ritvoortgang' },
{ service: 'auth', method: 'GET', path: '/me', label: 'Huidige sessie' },
{ service: 'auth', method: 'POST', path: '/login', label: 'Login (userCode + wachtwoord)' },
{ service: 'employee', method: 'GET', path: '/health', label: 'Employee health' },
{ service: 'errors', method: 'GET', path: '/error_log?limit=50', label: 'Recente foutmeldingen' },
]
const pretty = (value) => {
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
export default function RequestLab({ token }) {
const [route, setRoute] = useState(ROUTES[0])
const [body, setBody] = useState('')
const [result, setResult] = useState(null)
const [busy, setBusy] = useState(false)
const routesForService = ROUTES.filter((r) => r.service === route.service)
const pickService = (next) => {
const first = ROUTES.find((r) => r.service === next)
if (first) setRoute(first)
}
const run = async () => {
if (route.method !== 'GET' && !window.confirm('Deze aanvraag kan gegevens wijzigen. Doorgaan?')) return
setBusy(true)
setResult(null)
const started = performance.now()
try {
const response = await fetch(`${API_BASE[route.service]}${route.path}`, {
method: route.method,
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body || undefined,
})
const text = await response.text()
let parsed
try {
parsed = JSON.parse(text)
} catch {
parsed = text
}
setResult({
ok: response.ok,
status: response.status,
latency: Math.round(performance.now() - started),
body: parsed,
label: route.label,
})
} catch (err) {
setResult({
ok: false,
status: 0,
latency: Math.round(performance.now() - started),
body: { error: err.message },
label: route.label,
})
} finally {
setBusy(false)
}
}
return (
<div className="stack">
<section className="card">
<div className="section-header">
<div>
<h2>API-testomgeving</h2>
<p>Voer een van de goedgekeurde verzoeken uit om het live antwoord te bekijken.</p>
</div>
</div>
<div className="form-row">
<select value={route.service} onChange={(e) => pickService(e.target.value)} className="no-grow">
{SERVICE_OPTIONS.map((s) => (
<option key={s.key} value={s.key}>{s.label}</option>
))}
</select>
<select
value={`${route.method} ${route.path}`}
onChange={(e) => {
const found = routesForService.find((r) => `${r.method} ${r.path}` === e.target.value)
if (found) setRoute(found)
}}
className="flex-1"
>
{routesForService.map((r) => (
<option key={`${r.method} ${r.path}`} value={`${r.method} ${r.path}`}>
{r.method} {r.path} — {r.label}
</option>
))}
</select>
</div>
<div className="form-row">
<textarea
className="lab-body flex-1"
placeholder="Optionele request body (JSON)"
value={body}
onChange={(e) => setBody(e.target.value)}
/>
</div>
<div className="form-row">
<button className="no-grow" onClick={run} disabled={busy}>
{busy ? 'Uitvoeren…' : 'Verzoek uitvoeren'}
</button>
<span className="muted minw-0">
Verzoeken gaan rechtstreeks van deze browser naar de geconfigureerde service. Schrijfverzoeken vereisen een bevestiging.
</span>
</div>
</section>
<section className="card">
<div className="section-header">
<div>
<h2>Antwoord</h2>
<p>
{result
? `${result.label} · ${result.status || 'Netwerk'} · ${result.latency} ms`
: 'Nog geen verzoek uitgevoerd.'}
</p>
</div>
{result && (
<span className={`badge ${result.ok ? 'badge-success' : 'badge-danger'}`}>
{result.ok ? 'Succes' : 'Mislukt'}
</span>
)}
</div>
{result ? (
<pre className="lab-response">{pretty(result.body)}</pre>
) : (
<div className="lab-placeholder">
<IconTerminal size={28} />
<p>Kies een goedgekeurde route en voer deze uit om het live antwoord te inspecteren.</p>
</div>
)}
</section>
</div>
)
}
+7
View File
@@ -94,4 +94,11 @@ export const IconClock = (p) => (
<circle cx="12" cy="12" r="9" /> <circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" /> <path d="M12 7v5l3 2" />
</Svg> </Svg>
)
export const IconTerminal = (p) => (
<Svg {...p}>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="m7 9 3 3-3 3M13 15h4" />
</Svg>
) )
+51
View File
@@ -840,6 +840,57 @@ button.block {
display: block; display: block;
} }
/* ============================================================
Request lab
============================================================ */
.lab-body {
min-height: 90px;
resize: vertical;
font-family: var(--font-mono);
font-size: 0.85rem;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-xs);
padding: 10px 12px;
}
.lab-body:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15);
}
.lab-response {
margin: 4px 0 0;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 16px;
overflow: auto;
max-height: 480px;
font-family: var(--font-mono);
font-size: 0.82rem;
line-height: 1.5;
color: var(--text);
}
.lab-placeholder {
display: grid;
place-items: center;
text-align: center;
gap: 10px;
min-height: 220px;
color: var(--text-faint);
font-size: 0.9rem;
}
.lab-placeholder p {
margin: 0;
max-width: 340px;
}
/* ============================================================ /* ============================================================
Route stop board (drag & drop) Route stop board (drag & drop)
============================================================ */ ============================================================ */
+25 -72
View File
@@ -1,19 +1,20 @@
# Transit Control Room # Transit Control Room
`monitoring_ui` is a React + Vite operations dashboard for the XTransit service group. It gives an operator one place to answer four questions: `monitoring_ui` is a React + Vite operations dashboard for the XTransit service group. It gives an operator one place to answer three questions:
1. Are the services reachable right now? 1. Are the services reachable right now?
2. What kind of data does each service provide? 2. What kind of data does each service provide?
3. Which errors and bad requests have been recorded? 3. Where is the documentation for each service?
4. Can I safely inspect a real API response without building a curl command by hand?
The dashboard is a browser-only application. It does not introduce a database, server-side session store, or monitoring proxy. Requests go directly from the browser to the configured services. The dashboard is a browser-only application. It does not introduce a database, server-side session store, or monitoring proxy. Health requests go directly from the browser to the configured services.
Authenticated operations — the centralized error log, the request lab, and network management — live in the **admin console** (`admin_ui`, the Beheercentrum). This dashboard deliberately stays unauthenticated and read-only so a control-room wall display never needs a token.
## 1. What It Monitors ## 1. What It Monitors
| Surface | Default URL | Health request | Purpose | | Surface | Default URL | Health request | Purpose |
| ------------- | -------------------------------------------- | -------------- | --------------------------------------------------------- | | ------------- | -------------------------------------------- | -------------- | --------------------------------------------------------- |
| Auth API | `http://localhost:5000` | `GET /me` | Login, JWT sessions, and current-user information | | Auth API | `http://localhost:5000` | `GET /health` | Login, JWT sessions, and current-user information |
| Employee API | `https://employees.xtransit.testinstance.nl` | `GET /health` | Employee onboarding and employee records | | Employee API | `https://employees.xtransit.testinstance.nl` | `GET /health` | Employee onboarding and employee records |
| Transit API | `http://localhost:5001` | `GET /health` | Schedules, routes, GPS, fleet, and administration | | Transit API | `http://localhost:5001` | `GET /health` | Schedules, routes, GPS, fleet, and administration |
| Error Log API | `http://localhost:5003` | `GET /health` | Centralized service errors and bad requests | | Error Log API | `http://localhost:5003` | `GET /health` | Centralized service errors and bad requests |
@@ -28,9 +29,7 @@ The URLs are development defaults only. For a deployed dashboard, use the Connec
- `offline`: the browser could not establish a response, usually because the service is stopped, the URL is wrong, TLS failed, or CORS blocked the request. - `offline`: the browser could not establish a response, usually because the service is stopped, the URL is wrong, TLS failed, or CORS blocked the request.
- `checking`: the first poll has not completed yet. - `checking`: the first poll has not completed yet.
The Auth API uses `/me` because it does not currently expose a public `/health` endpoint. Without a JWT this normally appears as `degraded`, not as a broken service. Add a token in Connections when an authenticated Auth API signal is required. Transit health includes database reachability. Employee health currently confirms process reachability only. Auth health reads the public `/health` endpoint, which does not require a token. Admin UI health is supplied by the nginx configuration in `admin_ui/nginx.conf` and returns a small JSON document.
Transit health includes database reachability. Employee health currently confirms process reachability only. Admin UI health is supplied by the nginx configuration in `admin_ui/nginx.conf` and returns a small JSON document.
## 2. Features ## 2. Features
@@ -49,42 +48,15 @@ The top-level pulse shows how many surfaces are healthy and the average latency
The Service catalog is a human-readable map of the data each service serves. It is deliberately maintained as a small allowlisted catalog rather than attempting to discover every Express route at runtime. This keeps the dashboard understandable and avoids exposing internal routes or implementation details. The Service catalog is a human-readable map of the data each service serves. It is deliberately maintained as a small allowlisted catalog rather than attempting to discover every Express route at runtime. This keeps the dashboard understandable and avoids exposing internal routes or implementation details.
### Errors and bad requests ### Docs
The Errors & bad requests tab reads: The Docs tab bundles the project documentation hub (`monitoring_ui/docs/`) into a searchable sidebar. It renders every markdown file that ships with the dashboard and resolves in-document links. This is the same documentation referenced throughout the repository.
```text
GET /error_log?limit=80
Authorization: Bearer <admin-jwt>
```
The Error Log API restricts this endpoint to administrator tokens. The dashboard displays timestamp, service, route or sub-service, and normalized error text. The dashboard does not write new logs itself; the existing APIs already report their failures to `error_log_api`.
If the token is missing, the dashboard explains how to configure it. If the token is expired or not an administrator token, the API response is shown as a load failure rather than silently displaying an empty incident list.
### Request lab
The Request lab executes only known routes listed in `testerRoutes` inside `monitoring_ui/src/App.jsx`. It supports representative calls for:
- Auth API current-session inspection;
- Employee API health;
- Transit API health;
- public schedule progress;
- driver daily schedule;
- recent Error Log API entries.
The selected service, method, and path determine the destination. An optional JSON body can be entered for future write routes. Non-GET requests require a browser confirmation before execution.
This is an operator diagnostic tool, not a general-purpose arbitrary URL proxy. Keep the allowlist intentionally narrow when adding routes.
### Connections ### Connections
The Connections tab edits the five service base URLs and the optional admin JWT. Values are stored in `localStorage` under: The Connections tab edits the service base URLs used for health polling. Values are stored in `localStorage` under `transit-monitor-urls`. This makes local experimentation convenient, but it also means the URLs are readable by JavaScript running on the same origin. Use the dashboard only in a trusted browser profile.
- `transit-monitor-urls` The Connections screen intentionally does **not** accept tokens or passwords. Authenticated reads (error log, admin endpoints) are done from the admin console.
- `transit-monitor-token`
This makes local experimentation convenient, but it also means the token is readable by JavaScript running on the same origin. Use the dashboard only in a trusted browser profile and do not use it on a shared workstation with untrusted browser extensions.
## 3. Local Development ## 3. Local Development
@@ -112,7 +84,7 @@ monitoring_ui/
├── vite.config.mjs Vite React configuration ├── vite.config.mjs Vite React configuration
├── src/ ├── src/
│ ├── main.jsx React bootstrap │ ├── main.jsx React bootstrap
│ ├── App.jsx service model, polling, tabs, request lab │ ├── App.jsx service model, polling, tabs, docs viewer
│ └── styles.css responsive control-room styling │ └── styles.css responsive control-room styling
└── README.md short navigation pointer └── README.md short navigation pointer
``` ```
@@ -139,30 +111,20 @@ The Connections screen is useful when the same build needs to point at different
Because the dashboard calls services directly, every monitored API must allow the dashboard origin. The existing Auth, Transit, and Error Log services already expose permissive CORS middleware. Employee API has matching browser CORS middleware in `employee_api/index.js`. Because the dashboard calls services directly, every monitored API must allow the dashboard origin. The existing Auth, Transit, and Error Log services already expose permissive CORS middleware. Employee API has matching browser CORS middleware in `employee_api/index.js`.
For production, replace wildcard CORS with an explicit dashboard origin where practical. At minimum, allow: For production, replace wildcard CORS with an explicit dashboard origin where practical. At minimum, allow `GET` and `OPTIONS`.
- `GET`, `POST`, `OPTIONS`, and any methods used by approved tester routes;
- `Content-Type`;
- `Authorization`.
If the browser console reports a refused connection but direct `curl` succeeds, check CORS before changing the dashboard code. If the browser console reports a refused connection but direct `curl` succeeds, check CORS before changing the dashboard code.
## 5. Authentication and Security ## 5. Authentication and Security
The dashboard does not implement a second login system. It accepts a JWT created by the existing Auth API. Obtain a token through the normal admin login flow, then paste it into Connections. The dashboard is deliberately unauthenticated. It never stores or sends bearer tokens. All protected operations are handled by the admin console (`admin_ui`), which obtains a JWT through the normal Auth API login flow.
The Error Log API verifies both JWT validity and the `isAdmin` claim. A valid non-admin token is not sufficient to read centralized errors.
Operational safeguards: Operational safeguards:
- never commit `.env.local` or copied tokens; - never commit `.env.local`;
- avoid putting production JWTs in screenshots, issue reports, or browser history; - avoid putting production URLs, screenshots, or issue reports in public channels;
- keep the request lab allowlist small;
- require confirmation for all mutating requests;
- prefer read-only routes for routine diagnostics;
- use HTTPS for deployed APIs and dashboard access; - use HTTPS for deployed APIs and dashboard access;
- restrict CORS to known origins in production; - restrict CORS to known origins in production.
- consider moving token handling to an authenticated same-origin backend if the dashboard becomes a multi-user production console.
## 6. Deployment Options ## 6. Deployment Options
@@ -208,11 +170,7 @@ A reverse proxy can place the dashboard and service domains behind one TLS bound
### Auth API shows degraded ### Auth API shows degraded
`GET /me` requires a bearer token. Paste a current admin JWT into Connections. A `401` means the API answered and is reachable, but the supplied token is absent or invalid. `GET /health` should answer without a token. If it returns degraded, the API is reachable but responding with a non-success status. Note that `/me` requires a bearer token; that endpoint is only exercised from the admin console.
### Error list is empty
An empty list can mean either that no errors were returned or that the request was not authorized. Check the Errors tab status text and verify that the token has `isAdmin: true`. The Error Log API also requires its database table `data.errors` to exist.
### Employee API cannot be called from the dashboard ### Employee API cannot be called from the dashboard
@@ -243,21 +201,18 @@ To add a service:
1. Add its base URL to `configured`. 1. Add its base URL to `configured`.
2. Add its metadata to `serviceMeta`. 2. Add its metadata to `serviceMeta`.
3. Add a health path to `healthPaths`. 3. Add a health path to `healthPaths`.
4. Add representative safe routes to `testerRoutes`. 4. Add its environment variable to this document and `.env.local` examples.
5. Add its environment variable to this document and `.env.local` examples. 5. Confirm the service has CORS headers and an appropriate health endpoint.
6. Confirm the service has CORS headers and an appropriate health endpoint. 6. Run `npm run build` and perform a browser smoke test.
7. Run `npm run build` and perform a browser smoke test.
To add a request route, include a short label and keep the route literal. Avoid user-entered arbitrary URLs, unrestricted HTTP methods, or unrestricted request headers. Authenticated tooling belongs in `admin_ui`, not in this dashboard. If an admin-facing request lab or error viewer is needed, extend the admin console instead so this control-room surface stays unauthenticated.
Useful future extensions include: Useful future extensions include:
- a time-window chart backed by a real metrics store; - a time-window chart backed by a real metrics store;
- service version/build information in health payloads; - service version/build information in health payloads;
- threshold-based alerts for latency and repeated failures; - threshold-based alerts for latency and repeated failures;
- export of incident rows for an operator handoff; - an Admin UI card in the overview that includes deployment/build metadata.
- a same-origin authenticated backend if the tool becomes a shared production console;
- a dedicated `/health` endpoint for Auth API so authentication status and process health are separate signals.
## 9. Validation Checklist ## 9. Validation Checklist
@@ -275,7 +230,5 @@ Then verify in a browser:
- one healthy local service shows a payload and latency; - one healthy local service shows a payload and latency;
- an unavailable service is clearly marked offline; - an unavailable service is clearly marked offline;
- Connections persists URL changes after reload; - Connections persists URL changes after reload;
- an admin JWT loads Error Log API data; - no tab, control, or prompt requires a token or password;
- an invalid token produces a visible error state; - the layout remains usable at a narrow mobile width.
- each Request lab route targets the expected service;
- the layout remains usable at a narrow mobile width.
+2 -1
View File
@@ -161,7 +161,8 @@ The GitHub Actions workflow builds service images and pushes them to the configu
- use least-privilege database users; - use least-privilege database users;
- restrict CORS origins for production deployments; - restrict CORS origins for production deployments;
- keep admin JWTs out of logs and screenshots; - keep admin JWTs out of logs and screenshots;
- review allowlisted routes before expanding the monitoring request lab; - review allowlisted routes before expanding the admin console request lab;
- keep the monitoring control room unauthenticated and read-only;
- treat `DEV_MOCK=1` as a local development setting only. - treat `DEV_MOCK=1` as a local development setting only.
## Documentation Policy ## Documentation Policy
+17 -481
View File
@@ -77,45 +77,6 @@ const healthPaths = {
transit: "/health", transit: "/health",
errors: "/health", errors: "/health",
}; };
const testerRoutes = [
{
service: "auth",
method: "POST",
path: "/login",
label: "Login (userCode + password)",
},
{ service: "auth", method: "GET", path: "/me", label: "Current session" },
{
service: "employee",
method: "GET",
path: "/health",
label: "Employee health",
},
{
service: "transit",
method: "GET",
path: "/health",
label: "Transit health",
},
{
service: "transit",
method: "GET",
path: "/bus/public/schedules/1/progress",
label: "Public schedule progress",
},
{
service: "transit",
method: "GET",
path: "/bus/schedule/daily?date=2026-09-20",
label: "Driver daily schedule",
},
{
service: "errors",
method: "GET",
path: "/error_log?limit=25",
label: "Recent error log",
},
];
function pretty(value) { function pretty(value) {
try { try {
@@ -131,69 +92,11 @@ function App() {
JSON.parse(localStorage.getItem("transit-monitor-urls") || "null") || JSON.parse(localStorage.getItem("transit-monitor-urls") || "null") ||
configured, configured,
); );
const [token, setToken] = useState(
() => localStorage.getItem("transit-monitor-token") || "",
);
const [user, setUser] = useState(() =>
JSON.parse(localStorage.getItem("transit-monitor-user") || "null"),
);
const [health, setHealth] = useState({}); const [health, setHealth] = useState({});
const [history, setHistory] = useState({}); const [history, setHistory] = useState({});
const [errors, setErrors] = useState([]);
const [errorState, setErrorState] = useState("idle");
const [interval, setIntervalValue] = useState(30); const [interval, setIntervalValue] = useState(30);
const [lastRefresh, setLastRefresh] = useState(null); const [lastRefresh, setLastRefresh] = useState(null);
const [activeView, setActiveView] = useState("overview"); const [activeView, setActiveView] = useState("overview");
const [tester, setTester] = useState({
service: "transit",
method: "GET",
path: "/health",
body: "",
});
const [testerResult, setTesterResult] = useState(null);
const [busy, setBusy] = useState(false);
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState("");
const login = useCallback(
async (userCode, password) => {
const url = urls.auth;
if (!url) {
setLoginError("Auth API URL not configured");
return;
}
setLoginLoading(true);
setLoginError("");
try {
const response = await fetch(`${url}/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userCode, password }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`);
}
setToken(data.token);
setUser(data.user);
localStorage.setItem("transit-monitor-token", data.token);
localStorage.setItem("transit-monitor-user", JSON.stringify(data.user));
setLoginError("");
} catch (error) {
setLoginError(error.message);
} finally {
setLoginLoading(false);
}
},
[urls.auth],
);
const logout = useCallback(() => {
setToken("");
setUser(null);
localStorage.removeItem("transit-monitor-token");
localStorage.removeItem("transit-monitor-user");
}, []);
const checkService = useCallback( const checkService = useCallback(
async (key) => { async (key) => {
@@ -208,11 +111,8 @@ function App() {
checkedAt: new Date().toISOString(), checkedAt: new Date().toISOString(),
}; };
} }
const headers = token ? { Authorization: `Bearer ${token}` } : {};
try { try {
const response = await fetch(`${url}${healthPaths[key]}`, { const response = await fetch(`${url}${healthPaths[key]}`);
headers,
});
const body = await response.json().catch(() => null); const body = await response.json().catch(() => null);
return { return {
ok: response.ok, ok: response.ok,
@@ -231,7 +131,7 @@ function App() {
}; };
} }
}, },
[token, urls], [urls],
); );
const refreshHealth = useCallback(async () => { const refreshHealth = useCallback(async () => {
@@ -254,92 +154,15 @@ function App() {
setLastRefresh(new Date()); setLastRefresh(new Date());
}, [checkService]); }, [checkService]);
const loadErrors = useCallback(async () => {
if (!token) {
setErrors([]);
setErrorState("missing-token");
return;
}
setErrorState("loading");
try {
const response = await fetch(`${urls.errors}/error_log?limit=80`, {
headers: { Authorization: `Bearer ${token}` },
});
const body = await response.json();
if (!response.ok)
throw new Error(body.error || `HTTP ${response.status}`);
setErrors(body.errors || []);
setErrorState("ready");
} catch (error) {
setErrorState(error.message);
}
}, [token, urls.errors]);
useEffect(() => { useEffect(() => {
refreshHealth(); refreshHealth();
const timer = setInterval(refreshHealth, interval * 1000); const timer = setInterval(refreshHealth, interval * 1000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [refreshHealth, interval]); }, [refreshHealth, interval]);
useEffect(() => {
if (activeView === "incidents") loadErrors();
}, [activeView, loadErrors]);
const saveSettings = (nextUrls, nextToken) => { const saveUrls = (nextUrls) => {
setUrls(nextUrls); setUrls(nextUrls);
setToken(nextToken);
localStorage.setItem("transit-monitor-urls", JSON.stringify(nextUrls)); localStorage.setItem("transit-monitor-urls", JSON.stringify(nextUrls));
localStorage.setItem("transit-monitor-token", nextToken);
};
const runRequest = async () => {
const route = testerRoutes.find(
(item) =>
item.service === tester.service &&
item.method === tester.method &&
item.path === tester.path,
);
const isMutation = tester.method !== "GET";
if (
isMutation &&
!window.confirm("This request can change data. Continue?")
)
return;
setBusy(true);
setTesterResult(null);
const started = performance.now();
try {
const response = await fetch(`${urls[tester.service]}${tester.path}`, {
method: tester.method,
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(tester.body ? { "Content-Type": "application/json" } : {}),
},
body: tester.body || undefined,
});
const text = await response.text();
let body;
try {
body = JSON.parse(text);
} catch {
body = text;
}
setTesterResult({
ok: response.ok,
status: response.status,
latency: Math.round(performance.now() - started),
body,
label: route?.label,
});
} catch (error) {
setTesterResult({
ok: false,
status: 0,
latency: Math.round(performance.now() - started),
body: { error: error.message },
});
} finally {
setBusy(false);
}
}; };
const healthyCount = Object.values(health).filter((item) => item.ok).length; const healthyCount = Object.values(health).filter((item) => item.ok).length;
@@ -414,18 +237,6 @@ function App() {
> >
Docs Docs
</button> </button>
<button
className={activeView === "incidents" ? "active" : ""}
onClick={() => setActiveView("incidents")}
>
Errors & bad requests <b>{errors.length || ""}</b>
</button>
<button
className={activeView === "tester" ? "active" : ""}
onClick={() => setActiveView("tester")}
>
Request lab
</button>
<button <button
className={activeView === "settings" ? "active" : ""} className={activeView === "settings" ? "active" : ""}
onClick={() => setActiveView("settings")} onClick={() => setActiveView("settings")}
@@ -453,9 +264,7 @@ function App() {
<p className="eyebrow">DATA MAP</p> <p className="eyebrow">DATA MAP</p>
<h2>What each service serves</h2> <h2>What each service serves</h2>
</div> </div>
<span className="panel-count"> <span className="panel-count">{configuredCount} surfaces</span>
{configuredCount} surfaces
</span>
</div> </div>
<div className="data-list"> <div className="data-list">
{Object.entries(serviceMeta).map(([key, meta]) => ( {Object.entries(serviceMeta).map(([key, meta]) => (
@@ -473,28 +282,10 @@ function App() {
)} )}
{activeView === "catalog" && <Catalog />} {activeView === "catalog" && <Catalog />}
{activeView === "docs" && <DocsViewer />} {activeView === "docs" && <DocsViewer />}
{activeView === "incidents" && (
<Incidents errors={errors} state={errorState} reload={loadErrors} />
)}
{activeView === "tester" && (
<RequestLab
tester={tester}
setTester={setTester}
result={testerResult}
run={runRequest}
busy={busy}
token={token}
/>
)}
{activeView === "settings" && ( {activeView === "settings" && (
<Connections <Connections
urls={urls} urls={urls}
token={token} save={saveUrls}
user={user}
login={login}
logout={logout}
save={saveSettings}
loginLoading={loginLoading}
/> />
)} )}
</main> </main>
@@ -565,16 +356,18 @@ function QuickNotes() {
</span> </span>
</li> </li>
<li> <li>
<strong>Auth-aware checks</strong> <strong>Admin console</strong>
<span> <span>
Login with admin credentials to unlock /me and error history. The request lab, centralized error log, and network management live
in the admin console. Use the Beheercentrum for authenticated
operations.
</span> </span>
</li> </li>
<li> <li>
<strong>Safer testing</strong> <strong>Central documentation</strong>
<span> <span>
The request lab is intentionally allowlisted, with confirmation The Docs tab bundles every service, deployment, and API guide in
before writes. one searchable index.
</span> </span>
</li> </li>
</ul> </ul>
@@ -709,251 +502,8 @@ function Catalog() {
</section> </section>
); );
} }
function Incidents({ errors, state, reload }) { function Connections({ urls, save }) {
return (
<section className="panel incidents-panel">
<div className="panel-heading">
<div>
<p className="eyebrow">CENTRAL ERROR LOG</p>
<h2>Errors & bad requests</h2>
</div>
<button className="ghost-button" onClick={reload}>
Reload log
</button>
</div>
{state === "missing-token" && (
<Empty
title="Admin token required"
text="Add a JWT in Connections to read the protected error log."
/>
)}
{state !== "missing-token" && state !== "ready" && state !== "idle" && (
<Empty title="Could not load error log" text={state} />
)}
{state === "ready" && !errors.length && (
<Empty
title="No incidents returned"
text="The error log is clear for the selected window."
/>
)}
{errors.length > 0 && (
<div className="incident-table">
<div className="table-head">
<span>Time</span>
<span>Service / route</span>
<span>Error</span>
</div>
{errors.map((item) => (
<div className="table-row" key={item.id}>
<time>{new Date(item.date).toLocaleString()}</time>
<strong>
{item.service}
<small>{item.partofservice || "unspecified route"}</small>
</strong>
<span>{item.error}</span>
</div>
))}
</div>
)}
</section>
);
}
function Empty({ title, text }) {
return (
<div className="empty">
<strong>{title}</strong>
<span>{text}</span>
</div>
);
}
function RequestLab({ tester, setTester, result, run, busy, token }) {
const matching = testerRoutes.filter(
(route) => route.service === tester.service,
);
return (
<section className="lab-grid">
<div className="panel request-panel">
<div className="panel-heading">
<div>
<p className="eyebrow">CONTROLLED EXECUTION</p>
<h2>Live request lab</h2>
</div>
<span className="panel-count">
{token ? "token attached" : "anonymous"}
</span>
</div>
<label>
Service
<select
value={tester.service}
onChange={(event) => {
const route = testerRoutes.find(
(item) => item.service === event.target.value,
);
setTester({
...tester,
service: event.target.value,
method: route.method,
path: route.path,
});
}}
>
{Object.entries(serviceMeta)
.filter(([key]) => key !== "admin")
.map(([key, meta]) => (
<option key={key} value={key}>
{meta.label}
</option>
))}
</select>
</label>
<label>
Endpoint
<select
value={`${tester.method} ${tester.path}`}
onChange={(event) => {
const route = matching.find(
(item) => `${item.method} ${item.path}` === event.target.value,
);
setTester({ ...tester, method: route.method, path: route.path });
}}
>
{matching.map((route) => (
<option key={`${route.method} ${route.path}`}>
{route.method} {route.path}
</option>
))}
</select>
</label>
<label>
JSON body{" "}
<textarea
placeholder="Optional request body"
value={tester.body}
onChange={(event) =>
setTester({ ...tester, body: event.target.value })
}
/>
</label>
<button className="run-button" onClick={run} disabled={busy}>
{busy ? "Running..." : "Run request"}
</button>
<p className="helper">
Requests go directly from this browser to the configured service.
Write routes require a confirmation.
</p>
</div>
<div className="panel result-panel">
<div className="panel-heading">
<div>
<p className="eyebrow">RESPONSE</p>
<h2>
{result
? `${result.status || "Network"} · ${result.latency} ms`
: "Awaiting a request"}
</h2>
</div>
{result && (
<span
className={`status-pill ${result.ok ? "healthy" : "degraded"}`}
>
<i />
{result.ok ? "success" : "failed"}
</span>
)}
</div>
{result ? (
<pre className="response-code">{pretty(result.body)}</pre>
) : (
<div className="result-placeholder">
Pick a known route and execute it to inspect the live response.
</div>
)}
</div>
</section>
);
}
function Connections({ urls, token, user, login, logout, save, loginLoading }) {
const [draft, setDraft] = useState(urls); const [draft, setDraft] = useState(urls);
const [userCode, setUserCode] = useState("");
const [password, setPassword] = useState("");
const [loginError, setLoginError] = useState("");
const handleLogin = (e) => {
e.preventDefault();
setLoginError("");
login(userCode, password);
};
if (!token) {
return (
<section className="panel settings-panel">
<div className="panel-heading">
<div>
<p className="eyebrow">RUNTIME CONFIGURATION</p>
<h2>Connections & Login</h2>
</div>
<span className="panel-count">stored locally</span>
</div>
<p className="settings-intro">
Point the dashboard at local Docker ports or deployed domains. Nothing
here is sent anywhere except the service you choose to call.
</p>
<div className="connection-grid">
{Object.entries(serviceMeta).map(([key, meta]) => (
<label key={key}>
{meta.label}
<input
value={draft[key]}
onChange={(event) =>
setDraft({ ...draft, [key]: event.target.value })
}
/>
</label>
))}
</div>
<div className="login-form">
<h3>Admin Login</h3>
<form onSubmit={handleLogin}>
<label>
User Code
<input
type="number"
value={userCode}
onChange={(e) => setUserCode(e.target.value)}
placeholder="1234"
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
/>
</label>
{loginError && <div className="login-error">{loginError}</div>}
<button
type="submit"
className="run-button save-button"
disabled={loginLoading}
>
{loginLoading ? "Logging in..." : "Login"}
</button>
</form>
</div>
<button
className="run-button save-button"
onClick={() => save(draft, "")}
>
Save connections
</button>
</section>
);
}
return ( return (
<section className="panel settings-panel"> <section className="panel settings-panel">
@@ -966,7 +516,8 @@ function Connections({ urls, token, user, login, logout, save, loginLoading }) {
</div> </div>
<p className="settings-intro"> <p className="settings-intro">
Point the dashboard at local Docker ports or deployed domains. Nothing Point the dashboard at local Docker ports or deployed domains. Nothing
here is sent anywhere except the service you choose to call. here is sent anywhere except the service health checks themselves.
Authenticated operations now live in the admin console.
</p> </p>
<div className="connection-grid"> <div className="connection-grid">
{Object.entries(serviceMeta).map(([key, meta]) => ( {Object.entries(serviceMeta).map(([key, meta]) => (
@@ -981,24 +532,9 @@ function Connections({ urls, token, user, login, logout, save, loginLoading }) {
</label> </label>
))} ))}
</div> </div>
<div className="user-info">
<h3>Logged in as</h3>
<p>
<strong>User Code:</strong> {user?.userCode}
</p>
<p>
<strong>Role:</strong> {user?.job}
</p>
<p>
<strong>Admin:</strong> {user?.isAdmin ? "Yes" : "No"}
</p>
<button className="ghost-button" onClick={logout}>
Logout
</button>
</div>
<button <button
className="run-button save-button" className="run-button save-button"
onClick={() => save(draft, token)} onClick={() => save(draft)}
> >
Save connections Save connections
</button> </button>
@@ -1006,4 +542,4 @@ function Connections({ urls, token, user, login, logout, save, loginLoading }) {
); );
} }
export default App; export default App;
+2 -155
View File
@@ -196,10 +196,6 @@ textarea:focus {
color: #fff; color: #fff;
border-color: var(--mint); border-color: var(--mint);
} }
.tabs b {
color: var(--coral);
margin-left: 5px;
}
.service-grid { .service-grid {
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(5, 1fr);
@@ -434,7 +430,6 @@ textarea:focus {
margin-top: 5px; margin-top: 5px;
} }
.catalog-panel, .catalog-panel,
.incidents-panel,
.settings-panel { .settings-panel {
min-height: 500px; min-height: 500px;
} }
@@ -470,40 +465,7 @@ textarea:focus {
border-radius: 3px; border-radius: 3px;
padding: 8px; padding: 8px;
} }
.table-head, .empty {
.table-row {
display: grid;
grid-template-columns: 170px 220px 1fr;
gap: 20px;
padding: 13px 0;
}
.table-head {
color: var(--muted);
font:
10px "DM Mono",
monospace;
border-bottom: 1px solid var(--line);
}
.table-row {
font-size: 12px;
border-bottom: 1px solid #243131;
}
.table-row time,
.table-row > span {
color: var(--muted);
font-size: 11px;
}
.table-row strong small {
display: block;
color: var(--muted);
font:
10px "DM Mono",
monospace;
font-weight: 400;
margin-top: 5px;
}
.empty,
.result-placeholder {
display: grid; display: grid;
place-items: center; place-items: center;
text-align: center; text-align: center;
@@ -515,65 +477,6 @@ textarea:focus {
.empty strong { .empty strong {
color: #e8eeee; color: #e8eeee;
} }
.lab-grid {
display: grid;
grid-template-columns: 1fr 1.25fr;
gap: 12px;
}
.request-panel label,
.settings-panel label {
display: grid;
gap: 7px;
margin: 17px 0;
color: var(--muted);
font:
10px "DM Mono",
monospace;
text-transform: uppercase;
}
.request-panel select,
.request-panel textarea,
.settings-panel input {
width: 100%;
}
.request-panel textarea {
height: 130px;
resize: vertical;
}
.run-button {
width: 100%;
padding: 12px;
background: var(--mint);
color: #10201c;
border: 0;
border-radius: 4px;
font-weight: 800;
cursor: pointer;
}
.run-button:disabled {
opacity: 0.5;
cursor: wait;
}
.helper,
.settings-intro {
font-size: 11px;
line-height: 1.5;
color: var(--muted);
}
.response-code {
background: #0d1415;
border: 1px solid var(--line);
padding: 18px;
white-space: pre-wrap;
word-break: break-word;
min-height: 320px;
max-height: 520px;
overflow: auto;
color: #b8e9d6;
font:
11px "DM Mono",
monospace;
}
.connection-grid { .connection-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -582,9 +485,6 @@ textarea:focus {
.save-button { .save-button {
max-width: 180px; max-width: 180px;
} }
.incidents-panel .panel-heading {
align-items: center;
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.service-grid { .service-grid {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@@ -603,47 +503,6 @@ textarea:focus {
min-width: 150px; min-width: 150px;
} }
} }
.login-form {
border-top: 1px solid var(--line);
padding-top: 20px;
margin-top: 10px;
}
.login-form h3 {
font-size: 14px;
margin: 0 0 14px;
}
.login-form label {
display: grid;
gap: 7px;
margin: 12px 0;
color: var(--muted);
font: 10px "DM Mono", monospace;
text-transform: uppercase;
}
.login-form input {
width: 100%;
}
.login-error {
color: var(--coral);
font-size: 11px;
margin: 10px 0;
}
.user-info {
border-top: 1px solid var(--line);
padding-top: 20px;
margin-top: 10px;
}
.user-info h3 {
font-size: 14px;
margin: 0 0 12px;
}
.user-info p {
margin: 6px 0;
font-size: 12px;
}
.user-info button {
margin-top: 14px;
}
.docs-panel { .docs-panel {
min-height: 500px; min-height: 500px;
} }
@@ -886,21 +745,9 @@ textarea:focus {
.status-pill { .status-pill {
margin-top: 12px; margin-top: 12px;
} }
.catalog-block, .catalog-block {
.lab-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.table-head,
.table-row {
grid-template-columns: 1fr;
gap: 5px;
}
.table-head {
display: none;
}
.table-row {
padding: 15px 0;
}
.connection-grid { .connection-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }