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
+17 -481
View File
@@ -77,45 +77,6 @@ const healthPaths = {
transit: "/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) {
try {
@@ -131,69 +92,11 @@ function App() {
JSON.parse(localStorage.getItem("transit-monitor-urls") || "null") ||
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 [history, setHistory] = useState({});
const [errors, setErrors] = useState([]);
const [errorState, setErrorState] = useState("idle");
const [interval, setIntervalValue] = useState(30);
const [lastRefresh, setLastRefresh] = useState(null);
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(
async (key) => {
@@ -208,11 +111,8 @@ function App() {
checkedAt: new Date().toISOString(),
};
}
const headers = token ? { Authorization: `Bearer ${token}` } : {};
try {
const response = await fetch(`${url}${healthPaths[key]}`, {
headers,
});
const response = await fetch(`${url}${healthPaths[key]}`);
const body = await response.json().catch(() => null);
return {
ok: response.ok,
@@ -231,7 +131,7 @@ function App() {
};
}
},
[token, urls],
[urls],
);
const refreshHealth = useCallback(async () => {
@@ -254,92 +154,15 @@ function App() {
setLastRefresh(new Date());
}, [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(() => {
refreshHealth();
const timer = setInterval(refreshHealth, interval * 1000);
return () => clearInterval(timer);
}, [refreshHealth, interval]);
useEffect(() => {
if (activeView === "incidents") loadErrors();
}, [activeView, loadErrors]);
const saveSettings = (nextUrls, nextToken) => {
const saveUrls = (nextUrls) => {
setUrls(nextUrls);
setToken(nextToken);
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;
@@ -414,18 +237,6 @@ function App() {
>
Docs
</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
className={activeView === "settings" ? "active" : ""}
onClick={() => setActiveView("settings")}
@@ -453,9 +264,7 @@ function App() {
<p className="eyebrow">DATA MAP</p>
<h2>What each service serves</h2>
</div>
<span className="panel-count">
{configuredCount} surfaces
</span>
<span className="panel-count">{configuredCount} surfaces</span>
</div>
<div className="data-list">
{Object.entries(serviceMeta).map(([key, meta]) => (
@@ -473,28 +282,10 @@ function App() {
)}
{activeView === "catalog" && <Catalog />}
{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" && (
<Connections
urls={urls}
token={token}
user={user}
login={login}
logout={logout}
save={saveSettings}
loginLoading={loginLoading}
save={saveUrls}
/>
)}
</main>
@@ -565,16 +356,18 @@ function QuickNotes() {
</span>
</li>
<li>
<strong>Auth-aware checks</strong>
<strong>Admin console</strong>
<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>
</li>
<li>
<strong>Safer testing</strong>
<strong>Central documentation</strong>
<span>
The request lab is intentionally allowlisted, with confirmation
before writes.
The Docs tab bundles every service, deployment, and API guide in
one searchable index.
</span>
</li>
</ul>
@@ -709,251 +502,8 @@ function Catalog() {
</section>
);
}
function Incidents({ errors, state, reload }) {
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 }) {
function Connections({ urls, save }) {
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 (
<section className="panel settings-panel">
@@ -966,7 +516,8 @@ function Connections({ urls, token, user, login, logout, save, loginLoading }) {
</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.
here is sent anywhere except the service health checks themselves.
Authenticated operations now live in the admin console.
</p>
<div className="connection-grid">
{Object.entries(serviceMeta).map(([key, meta]) => (
@@ -981,24 +532,9 @@ function Connections({ urls, token, user, login, logout, save, loginLoading }) {
</label>
))}
</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
className="run-button save-button"
onClick={() => save(draft, token)}
onClick={() => save(draft)}
>
Save connections
</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;
border-color: var(--mint);
}
.tabs b {
color: var(--coral);
margin-left: 5px;
}
.service-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
@@ -434,7 +430,6 @@ textarea:focus {
margin-top: 5px;
}
.catalog-panel,
.incidents-panel,
.settings-panel {
min-height: 500px;
}
@@ -470,40 +465,7 @@ textarea:focus {
border-radius: 3px;
padding: 8px;
}
.table-head,
.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 {
.empty {
display: grid;
place-items: center;
text-align: center;
@@ -515,65 +477,6 @@ textarea:focus {
.empty strong {
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 {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -582,9 +485,6 @@ textarea:focus {
.save-button {
max-width: 180px;
}
.incidents-panel .panel-heading {
align-items: center;
}
@media (max-width: 1100px) {
.service-grid {
grid-template-columns: repeat(3, 1fr);
@@ -603,47 +503,6 @@ textarea:focus {
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 {
min-height: 500px;
}
@@ -886,21 +745,9 @@ textarea:focus {
.status-pill {
margin-top: 12px;
}
.catalog-block,
.lab-grid {
.catalog-block {
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 {
grid-template-columns: 1fr;
}