14 Commits

Author SHA1 Message Date
Sven laptop 89e4f90fc3 Fix monitoring UI 2026-09-23 17:23:04 +02:00
Sven laptop 39b0b07a77 updated docker build to always deplyo 2026-09-23 17:22:14 +02:00
Sven laptop 48c710bfbf Fix admin check on monitoring UI
Build and Push Docker Images / detect-changes (push) Successful in 12s
Build and Push Docker Images / build-auth-api (push) Has been skipped
Build and Push Docker Images / build-employee-api (push) Has been skipped
Build and Push Docker Images / build-transit-api (push) Has been skipped
Build and Push Docker Images / build-error-log-api (push) Has been skipped
Build and Push Docker Images / build-admin-ui (push) Has been skipped
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m15s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
2026-09-23 17:12:41 +02:00
Sven laptop 1c055d84e8 Merge branch 'driver_ui' into dev
Build and Push Docker Images / build-auth-api (push) Has been skipped
Build and Push Docker Images / build-employee-api (push) Has been skipped
Build and Push Docker Images / detect-changes (push) Successful in 10s
Build and Push Docker Images / build-transit-api (push) Has been skipped
Build and Push Docker Images / build-error-log-api (push) Has been skipped
Build and Push Docker Images / build-admin-ui (push) Has been skipped
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m30s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
2026-09-23 10:00:20 +02:00
Sven laptop d7100e6f2a Add docs tab with local READMEs and login form to monitoring UI
Build and Push Docker Images / detect-changes (pull_request) Successful in 13s
Build and Push Docker Images / build-transit-api (pull_request) Has been skipped
Build and Push Docker Images / build-monitoring-ui (pull_request) Has been skipped
Build and Push Docker Images / deploy-test-node (pull_request) Has been skipped
Build and Push Docker Images / build-employee-api (pull_request) Has been skipped
Build and Push Docker Images / build-auth-api (pull_request) Has been skipped
Build and Push Docker Images / build-error-log-api (pull_request) Has been skipped
Build and Push Docker Images / build-admin-ui (pull_request) Has been skipped
Build and Push Docker Images / detect-changes (push) Successful in 9s
Build and Push Docker Images / build-auth-api (push) Successful in 1m0s
Build and Push Docker Images / build-employee-api (push) Successful in 59s
Build and Push Docker Images / build-transit-api (push) Successful in 57s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m4s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m17s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m14s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
2026-09-22 00:14:01 +02:00
Sven laptop 6e0b7e4793 Empty commit for docker-compose.yml
Build and Push Docker Images / detect-changes (push) Successful in 9s
Build and Push Docker Images / build-auth-api (push) Has been skipped
Build and Push Docker Images / build-employee-api (push) Has been skipped
Build and Push Docker Images / build-transit-api (push) Has been skipped
Build and Push Docker Images / build-error-log-api (push) Has been skipped
Build and Push Docker Images / build-admin-ui (push) Has been skipped
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m11s
Build and Push Docker Images / deploy-test-node (push) Successful in 4s
2026-09-22 00:01:30 +02:00
Sven laptop da5c6b2839 added docs tab and improved authentication on dash
Build and Push Docker Images / detect-changes (push) Successful in 9s
Build and Push Docker Images / build-transit-api (push) Has been skipped
Build and Push Docker Images / build-auth-api (push) Has been skipped
Build and Push Docker Images / build-employee-api (push) Has been skipped
Build and Push Docker Images / build-error-log-api (push) Has been skipped
Build and Push Docker Images / build-admin-ui (push) Has been skipped
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m11s
Build and Push Docker Images / deploy-test-node (push) Has been skipped
2026-09-21 23:53:54 +02:00
Sven laptop 38782bca6d fixed 304 errors and removed admin ui from dash
Build and Push Docker Images / detect-changes (push) Successful in 10s
Build and Push Docker Images / build-auth-api (push) Has been skipped
Build and Push Docker Images / build-employee-api (push) Has been skipped
Build and Push Docker Images / build-transit-api (push) Has been skipped
Build and Push Docker Images / build-error-log-api (push) Has been skipped
Build and Push Docker Images / build-admin-ui (push) Has been skipped
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m15s
Build and Push Docker Images / deploy-test-node (push) Has been skipped
2026-09-21 23:36:07 +02:00
Sven laptop a17125bdee fixed monitor api polling
Build and Push Docker Images / detect-changes (push) Successful in 10s
Build and Push Docker Images / build-auth-api (push) Successful in 1m6s
Build and Push Docker Images / build-employee-api (push) Successful in 1m2s
Build and Push Docker Images / build-transit-api (push) Successful in 58s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m10s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m15s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m16s
Build and Push Docker Images / deploy-test-node (push) Successful in 4s
2026-09-21 19:59:53 +02:00
Sven laptop 217dfd3abc Update readme's, branches and actions
Build and Push Docker Images / detect-changes (push) Successful in 9s
Build and Push Docker Images / build-auth-api (push) Successful in 1m5s
Build and Push Docker Images / build-employee-api (push) Successful in 1m1s
Build and Push Docker Images / build-transit-api (push) Successful in 57s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m5s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m16s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m13s
Build and Push Docker Images / deploy-test-node (push) Successful in 5s
2026-09-21 00:15:05 +02:00
Sven laptop 635a130f0b ──────
Build and Push Docker Images / detect-changes (push) Successful in 11s
Build and Push Docker Images / build-auth-api (push) Successful in 1m0s
Build and Push Docker Images / build-employee-api (push) Successful in 1m2s
Build and Push Docker Images / build-transit-api (push) Successful in 1m1s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m5s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m13s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m15s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
### Summary of Changes
  1. Documentation Consolidation:
      • Relocated the docs/ folder and all repository .md files (README.md, DEPLOYMENT.md, DOCKER_README.md,
      DOMAINS_README.md, GETTING_STARTED.md, CI_CD_PORTAINER_GUIDE.md, ADMIN_DEPLOYMENT_SUMMARY.md,
      SECURITY_PRIVATE_FILES.md, handoff.md, azure-project-plan.md, plus service-specific READMEs) into .
      • Updated document links in README.md.
  2. AI Agent Project Map:
      • Created PROJECT_MAP.md in the project root containing complete architecture diagrams, service specs, ports, DB
      schemas, environment variables, and developer workflows.
      • Added PROJECT_MAP.md to .gitignore.
  3. Monitoring UI & Infrastructure Integration:
      • Added monitoring_ui service to docker-compose.yml (port 5175).
      • Configured Gitea workflow docker-build.yml for monitoring_ui builds.
      • Added CORS middleware headers in index.js.
      • Added nginx.conf copy step in Dockerfile.

  ──────
  ### Recommended Commit Message

    docs: consolidate project documentation into monitoring_ui and add agent project map

    - Move all markdown documentation files and docs/ folder into monitoring_ui/docs/
    - Update documentation hub index (monitoring_ui/docs/README.md) with relative paths
    - Generate comprehensive AI agent reference map (PROJECT_MAP.md) in repository root
    - Add PROJECT_MAP.md and monitoring_ui build output/env overrides to .gitignore
    - Integrate monitoring_ui service into docker-compose.yml and Gitea CI workflow
    - Configure CORS handling in employee_api for monitoring dashboard access
    ──────
  ### Alternative Short Commit Message

    refactor(docs): move docs to monitoring_ui and create AI agent PROJECT_MAP.md
2026-09-20 23:33:55 +02:00
Sven laptop f935bbeb8c updated transit api
Build and Push Docker Images / detect-changes (push) Successful in 10s
Build and Push Docker Images / build-auth-api (push) Successful in 1m0s
Build and Push Docker Images / build-employee-api (push) Successful in 1m3s
Build and Push Docker Images / build-transit-api (push) Successful in 59s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m9s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m20s
Build and Push Docker Images / deploy-test-node (push) Successful in 4s
2026-09-18 20:08:10 +02:00
Sven laptop 1154d1d658 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
2026-09-18 19:26:05 +02:00
Sven laptop 3f0a02aa04 feat: add weekly schedule shifts and bulk departures
Build and Push Docker Images / detect-changes (push) Successful in 11s
Build and Push Docker Images / build-auth-api (push) Successful in 1m1s
Build and Push Docker Images / build-employee-api (push) Successful in 59s
Build and Push Docker Images / build-transit-api (push) Successful in 56s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m11s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m37s
Build and Push Docker Images / deploy-test-node (push) Successful in 5s
2026-09-18 18:00:51 +02:00
64 changed files with 12499 additions and 3062 deletions
+536
View File
@@ -0,0 +1,536 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Command Center | XTransit</title>
<link rel="stylesheet" href="./theme.css" />
</head>
<body>
<div class="preview-root">
<header class="preview-header">
<div class="preview-header__brand">
<span class="preview-header__logo">XT</span>
<span>XTransit Operations</span>
</div>
<div class="preview-header__actions">
<button
class="preview-btn preview-btn--ghost"
aria-label="Search services"
>
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" stroke-width="1.8"></circle>
<path
d="m16 16 4.5 4.5"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<span>Search</span>
</button>
<button
class="preview-btn preview-btn--ghost"
aria-label="View notifications"
>
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M18 9a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9Z"
stroke-width="1.8"
stroke-linejoin="round"
></path>
<path
d="M10 21h4"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<span class="preview-badge preview-badge--warning">2</span>
</button>
<span class="preview-avatar" aria-label="Signed in as Ava Morgan"
>AM</span
>
</div>
</header>
<nav class="preview-nav" aria-label="Primary navigation">
<a class="preview-nav__link preview-nav__link--active" href="#">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<rect
x="3.5"
y="3.5"
width="7"
height="7"
rx="1"
stroke-width="1.8"
></rect>
<rect
x="13.5"
y="3.5"
width="7"
height="7"
rx="1"
stroke-width="1.8"
></rect>
<rect
x="3.5"
y="13.5"
width="7"
height="7"
rx="1"
stroke-width="1.8"
></rect>
<rect
x="13.5"
y="13.5"
width="7"
height="7"
rx="1"
stroke-width="1.8"
></rect>
</svg>
<span>Command Center</span>
</a>
<a class="preview-nav__link" href="./service-catalog.html">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M4 6.5h16M4 12h16M4 17.5h16"
stroke-width="1.8"
stroke-linecap="round"
></path>
<circle
cx="7"
cy="6.5"
r="1.4"
fill="currentColor"
stroke="none"
></circle>
<circle
cx="12"
cy="12"
r="1.4"
fill="currentColor"
stroke="none"
></circle>
<circle
cx="17"
cy="17.5"
r="1.4"
fill="currentColor"
stroke="none"
></circle>
</svg>
<span>Service Catalog</span>
</a>
<a class="preview-nav__link" href="./request-lab.html">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M5 4.5h14v15H5z"
stroke-width="1.8"
stroke-linejoin="round"
></path>
<path
d="M8 8h8M8 12h8M8 16h5"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<span>Request Lab</span>
</a>
<a class="preview-nav__link" href="./incident-log.html">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 3.5 20 7v5c0 4.2-3.2 7.4-8 8.8C7.2 19.4 4 16.2 4 12V7l8-3.5Z"
stroke-width="1.8"
stroke-linejoin="round"
></path>
<path
d="M12 8v4M12 15.5v.1"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<span>Incident Log</span>
</a>
</nav>
<div class="preview-shell">
<aside class="preview-sidebar" aria-label="Command center sections">
<div class="preview-sidebar__section">Monitor</div>
<a
class="preview-sidebar__item preview-sidebar__item--active"
href="#"
>
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M4 17.5h16M6.5 14V9M12 14V5M17.5 14v-3"
stroke-width="1.8"
stroke-linecap="round"
></path>
<path
d="M4 19.5h16"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<span>Overview</span>
</a>
<a class="preview-sidebar__item" href="./service-catalog.html">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<rect
x="4"
y="4"
width="6"
height="6"
rx="1"
stroke-width="1.8"
></rect>
<rect
x="14"
y="4"
width="6"
height="6"
rx="1"
stroke-width="1.8"
></rect>
<rect
x="4"
y="14"
width="6"
height="6"
rx="1"
stroke-width="1.8"
></rect>
<rect
x="14"
y="14"
width="6"
height="6"
rx="1"
stroke-width="1.8"
></rect>
</svg>
<span>Services</span>
</a>
<a class="preview-sidebar__item" href="./incident-log.html">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M5 4.5h14v15H5z"
stroke-width="1.8"
stroke-linejoin="round"
></path>
<path
d="M8 8h8M8 12h6M8 16h4"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<span>Activity</span>
</a>
<div class="preview-sidebar__section">Workspace</div>
<a class="preview-sidebar__item" href="./request-lab.html">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="m8 8-4 4 4 4M16 8l4 4-4 4M14 5l-4 14"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
<span>Request Lab</span>
</a>
<a class="preview-sidebar__item" href="./incident-log.html">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 4.5v8l5 3"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
<circle cx="12" cy="12" r="8.5" stroke-width="1.8"></circle>
</svg>
<span>Incident Log</span>
</a>
</aside>
<main class="preview-main">
<section class="preview-hero" aria-labelledby="page-title">
<div class="preview-hero__mesh"></div>
<div class="preview-hero__eyebrow">Operations / Live overview</div>
<h1 class="preview-hero__title" id="page-title">Command Center</h1>
<p class="preview-hero__subtitle">
Current health, polling freshness, and important service signals
across the XTransit platform.
</p>
<div class="preview-hero__actions">
<button class="preview-btn preview-btn--primary">
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 4v8l4 2"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
<path
d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"
stroke-width="1.8"
stroke-linecap="round"
></path>
<path
d="M17 4h3v3"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
<span>Run health check</span>
</button>
<span class="preview-badge preview-badge--success"
><span class="preview-badge__dot"></span>Polling every 60
seconds</span
>
</div>
</section>
<section class="preview-kpi-row" aria-label="Platform health summary">
<article class="preview-kpi">
<svg
class="preview-icon preview-icon--lg"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 19.5h16M6.5 16V8M12 16V4.5M17.5 16v-5"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<span class="preview-kpi__label">Monitored services</span>
<strong class="preview-kpi__value">4</strong>
<span class="preview-kpi__delta">Across APIs and admin UI</span>
</article>
<article class="preview-kpi">
<svg
class="preview-icon preview-icon--lg"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="m5 12 4.5 4.5L19 7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
></path>
<circle cx="12" cy="12" r="9" stroke-width="1.8"></circle>
</svg>
<span class="preview-kpi__label">Operational</span>
<strong class="preview-kpi__value">3</strong>
<span class="preview-kpi__delta preview-kpi__delta--up"
>75% of monitored services</span
>
</article>
<article class="preview-kpi">
<svg
class="preview-icon preview-icon--lg"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 4.5 20 19H4L12 4.5Z"
stroke-width="1.8"
stroke-linejoin="round"
></path>
<path
d="M12 10v4M12 16.5v.1"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<span class="preview-kpi__label">Degraded</span>
<strong class="preview-kpi__value">1</strong>
<span class="preview-kpi__delta"
>Error Log API needs attention</span
>
</article>
<article class="preview-kpi">
<svg
class="preview-icon preview-icon--lg"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="12" cy="12" r="8.5" stroke-width="1.8"></circle>
<path
d="M12 7v5l3.5 2"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
<span class="preview-kpi__label">Last poll</span>
<strong class="preview-kpi__value">18 sec ago</strong>
<span class="preview-kpi__delta">Next poll in 42 seconds</span>
</article>
</section>
<section class="preview-two-column" aria-label="Service signals">
<div>
<div class="preview-section-title">
<h2 class="preview-section-title__h">
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 12h4l2-7 4 14 2-7h4"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
Service health
</h2>
<span class="preview-section-title__hint"
>Live response checks</span
>
</div>
<div class="preview-table-wrap">
<table class="preview-table">
<thead>
<tr>
<th>Service</th>
<th>Latency</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<strong>Transit API</strong><br /><span
class="preview-section-title__hint"
>/health</span
>
</td>
<td>184 ms</td>
<td>
<span class="preview-badge preview-badge--success"
><span class="preview-badge__dot"></span
>Operational</span
>
</td>
</tr>
<tr>
<td>
<strong>Employee API</strong><br /><span
class="preview-section-title__hint"
>/health</span
>
</td>
<td>96 ms</td>
<td>
<span class="preview-badge preview-badge--success"
><span class="preview-badge__dot"></span
>Operational</span
>
</td>
</tr>
<tr>
<td>
<strong>Error Log API</strong><br /><span
class="preview-section-title__hint"
>/health</span
>
</td>
<td>121 ms</td>
<td>
<span class="preview-badge preview-badge--warning"
><span class="preview-badge__dot"></span
>Degraded</span
>
</td>
</tr>
<tr>
<td>
<strong>Admin UI</strong><br /><span
class="preview-section-title__hint"
>http://localhost:5173</span
>
</td>
<td>42 ms</td>
<td>
<span class="preview-badge preview-badge--success"
><span class="preview-badge__dot"></span
>Operational</span
>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div>
<div class="preview-section-title">
<h2 class="preview-section-title__h">
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4.5 5.5h15v13h-15z"
stroke-width="1.8"
stroke-linejoin="round"
></path>
<path
d="M8 9h8M8 13h5"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
Recent poll activity
</h2>
<span class="preview-section-title__hint">Updating</span>
</div>
<div
class="preview-card"
aria-label="Recent poll activity loading"
>
<div class="preview-skeleton preview-skeleton--title"></div>
<div class="preview-skeleton preview-skeleton--line"></div>
<div class="preview-skeleton preview-skeleton--line"></div>
<div class="preview-skeleton preview-skeleton--line"></div>
<div class="preview-skeleton preview-skeleton--block"></div>
</div>
</div>
</section>
<div class="preview-action-bar">
<span class="preview-section-title__hint"
>Signed in as Ava Morgan · Operator access</span
>
<button class="preview-btn preview-btn--secondary">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M4 12a8 8 0 1 0 2.3-5.7"
stroke-width="1.8"
stroke-linecap="round"
></path>
<path
d="M4 5v5h5"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
<span>Refresh overview</span>
</button>
</div>
</main>
</div>
</div>
</body>
</html>
+479
View File
@@ -0,0 +1,479 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Incident Log | Transit Operations</title>
<link rel="stylesheet" href="./theme.css" />
</head>
<body>
<div class="preview-root">
<header class="preview-header">
<div class="preview-header__brand">
<span class="preview-header__logo">TO</span>
<span>Transit Operations</span>
</div>
<div class="preview-header__actions">
<label class="preview-form__input" aria-label="Search console">
<svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="11" cy="11" r="6.5" stroke-width="1.8"></circle>
<path
d="m16 16 5 5"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<input
type="search"
placeholder="Search console"
aria-label="Search console"
style="
border: 0;
background: transparent;
outline: 0;
font: inherit;
width: 135px;
color: inherit;
"
/>
</label>
<a
class="preview-header__action"
href="#notifications"
aria-label="Notifications"
>
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M18 9a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9ZM10 21h4"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
</a>
<span class="preview-avatar" aria-label="Signed in as Maya Chen"
>MC</span
>
</div>
</header>
<nav class="preview-nav" aria-label="Primary navigation">
<a class="preview-nav__link" href="./command-center.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<rect x="4" y="4" width="6" height="6" stroke-width="1.8"></rect>
<rect x="14" y="4" width="6" height="6" stroke-width="1.8"></rect>
<rect x="4" y="14" width="6" height="6" stroke-width="1.8"></rect>
<rect
x="14"
y="14"
width="6"
height="6"
stroke-width="1.8"
></rect></svg
>Command center</a
>
<a class="preview-nav__link" href="./service-catalog.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 20V5l8-2 8 2v15M4 20h16M8 8h2m4 0h2m-8 4h2m4 0h2m-8 4h2m4 0h2"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Service catalog</a
>
<a class="preview-nav__link" href="./request-lab.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="m8 8-4 4 4 4M16 8l4 4-4 4M14 4l-4 16"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Request lab</a
>
<a
class="preview-nav__link preview-nav__link--active"
href="#"
aria-current="page"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Incident log</a
>
</nav>
<div class="preview-shell">
<aside class="preview-sidebar" aria-label="Incident log navigation">
<div class="preview-sidebar__section">Monitor</div>
<a class="preview-sidebar__item" href="./command-center.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 13h4l2-7 4 12 2-5h4"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Command center</a
>
<a
class="preview-sidebar__item preview-sidebar__item--active"
href="#"
aria-current="page"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Incident log</a
>
<div class="preview-sidebar__section">Services</div>
<a class="preview-sidebar__item" href="./service-catalog.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 20V5l8-2 8 2v15M4 20h16M8 8h2m4 0h2m-8 4h2m4 0h2"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Service catalog</a
>
<a class="preview-sidebar__item" href="./request-lab.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="m8 8-4 4 4 4M16 8l4 4-4 4M14 4l-4 16"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Request lab</a
>
</aside>
<main class="preview-main">
<div class="preview-section-title">
<div>
<div class="preview-card__meta">MONITORING / INCIDENTS</div>
<h1
class="preview-hero__title"
style="color: var(--color-text); margin-top: 4px"
>
Incident log
</h1>
</div>
<a
class="preview-btn preview-btn--primary"
href="#refresh-incidents"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M20 11a8 8 0 1 0 1 4M20 5v6h-6"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Refresh incidents</a
>
</div>
<div
class="preview-action-bar"
style="justify-content: flex-start; padding: 0; border-top: 0"
>
<label class="preview-form__input"
><span class="preview-card__meta">Service</span
><select aria-label="Filter by service">
<option>All services</option>
<option>Auth API</option>
<option>Employee API</option>
<option>Transit API</option>
<option>Error Log API</option>
<option>Admin UI</option>
</select></label
>
<label class="preview-form__input"
><span class="preview-card__meta">Severity</span
><select aria-label="Filter by severity">
<option>All severities</option>
<option>Critical</option>
<option>Error</option>
<option>Warning</option>
<option>Info</option>
</select></label
>
<label class="preview-form__input"
><span class="preview-card__meta">Window</span
><select aria-label="Filter by time window">
<option>Last 24 hours</option>
<option>Last 7 days</option>
<option>Last 30 days</option>
</select></label
>
<span class="preview-card__meta"
>3 incidents in the selected window</span
>
</div>
<div class="preview-tabs" role="tablist" aria-label="Incident views">
<a
class="preview-tabs__tab preview-tabs__tab--active"
href="#all-incidents"
role="tab"
aria-selected="true"
>All incidents</a
>
<a
class="preview-tabs__tab"
href="#delivery-status"
role="tab"
aria-selected="false"
>Delivery status</a
>
</div>
<section id="all-incidents" aria-labelledby="all-incidents-heading">
<div class="preview-section-title">
<h2 class="preview-section-title__h" id="all-incidents-heading">
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
Recent activity
</h2>
<span class="preview-section-title__hint"
>Normalized from five connected services</span
>
</div>
<div class="preview-table-wrap">
<table class="preview-table">
<thead>
<tr>
<th>Time</th>
<th>Service</th>
<th>Code</th>
<th>Message</th>
<th>Severity</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>09:42:18</strong></td>
<td>Error Log API</td>
<td><code>BAD_REQUEST</code></td>
<td>Missing route identifier</td>
<td>
<span class="preview-badge preview-badge--warning"
><span class="preview-badge__dot"></span>Warning</span
>
</td>
</tr>
<tr>
<td><strong>09:37:04</strong></td>
<td>Transit API</td>
<td><code>FORBIDDEN</code></td>
<td>Admin role required</td>
<td>
<span class="preview-badge"
><span class="preview-badge__dot"></span>Info</span
>
</td>
</tr>
<tr>
<td><strong>09:18:51</strong></td>
<td>Employee API</td>
<td><code>INTERNAL_ERROR</code></td>
<td>Employee lookup timed out</td>
<td>
<span class="preview-badge preview-badge--danger"
><span class="preview-badge__dot"></span>Critical</span
>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="preview-card-list" aria-labelledby="severity-heading">
<div class="preview-section-title">
<h2 class="preview-section-title__h" id="severity-heading">
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 3 3 20h18L12 3ZM12 9v5m0 3h.01"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
Severity summary
</h2>
<span class="preview-section-title__hint">Current window</span>
</div>
<div class="preview-card-grid">
<article class="preview-card">
<div class="preview-card__title">
<svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 3 3 20h18L12 3Zm0 6v5m0 3h.01"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
Warning
</div>
<strong class="preview-kpi__value">1</strong>
<p class="preview-card__body">
Requires review before the next dispatch cycle.
</p>
</article>
<article class="preview-card">
<div class="preview-card__title">
<svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="12" cy="12" r="8.5" stroke-width="1.8"></circle>
<path
d="M12 8v4l2.5 2"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
Info
</div>
<strong class="preview-kpi__value">1</strong>
<p class="preview-card__body">
Access policy event recorded from Transit API.
</p>
</article>
<article class="preview-card">
<div class="preview-card__title">
<svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 3 3 20h18L12 3Zm0 6v5m0 3h.01"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
Critical
</div>
<strong class="preview-kpi__value">1</strong>
<p class="preview-card__body">
Employee lookup timeout needs immediate attention.
</p>
</article>
</div>
</section>
<section
id="delivery-status"
class="preview-error"
aria-labelledby="delivery-heading"
>
<svg
class="preview-icon preview-icon--lg preview-error__icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 3 3 20h18L12 3Zm0 6v5m0 3h.01"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
<div class="preview-error__body">
<strong class="preview-error__title" id="delivery-heading"
>Error log API delayed</strong
><span class="preview-error__text"
>Last successful pull was 3 minutes ago</span
>
</div>
<a class="preview-btn preview-btn--secondary" href="#retry-delivery"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M20 11a8 8 0 1 0 1 4M20 5v6h-6"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Retry</a
>
</section>
</main>
</div>
</div>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
{
"generatedAt": "2026-09-20T00:00:00.000Z",
"previewStatus": "generating",
"pages": [
{
"slug": "command-center",
"title": "Command Center",
"route": "/",
"status": "pending"
},
{
"slug": "service-catalog",
"title": "Service Catalog",
"route": "/services",
"status": "pending"
},
{
"slug": "request-lab",
"title": "Request Lab",
"route": "/requests",
"status": "pending"
},
{
"slug": "incident-log",
"title": "Incident Log",
"route": "/incidents",
"status": "pending"
}
]
}
+495
View File
@@ -0,0 +1,495 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Request Lab | XTransit Operations</title>
<link rel="stylesheet" href="./theme.css" />
</head>
<body>
<div class="preview-root">
<header class="preview-header">
<div class="preview-header__brand">
<span class="preview-header__logo" aria-hidden="true">XT</span>
<span>XTransit Operations</span>
</div>
<div class="preview-header__actions">
<label class="preview-header__search" aria-label="Search operations">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" stroke-width="1.8"></circle>
<path
d="m16 16 4.2 4.2"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<input
class="preview-form__input"
type="search"
placeholder="Search operations"
value=""
/>
</label>
<a class="preview-header__action" href="#" aria-label="Notifications">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M18 9a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
</a>
<span class="preview-avatar" aria-label="Signed in as Maya Chen"
>MC</span
>
</div>
</header>
<nav class="preview-nav" aria-label="Primary navigation">
<a class="preview-nav__link" href="./command-center.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<rect
x="4"
y="4"
width="6"
height="6"
rx="1"
stroke-width="1.8"
></rect>
<rect
x="14"
y="4"
width="6"
height="6"
rx="1"
stroke-width="1.8"
></rect>
<rect
x="4"
y="14"
width="6"
height="6"
rx="1"
stroke-width="1.8"
></rect>
<rect
x="14"
y="14"
width="6"
height="6"
rx="1"
stroke-width="1.8"
></rect></svg
>Command Center</a
>
<a class="preview-nav__link" href="./service-catalog.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 5h14v14H5zM8 9h8M8 13h5"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Service Catalog</a
>
<a
class="preview-nav__link preview-nav__link--active"
href="#"
aria-current="page"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 6h16M4 12h10M4 18h16"
stroke-width="1.8"
stroke-linecap="round"
></path>
<circle cx="17" cy="12" r="2" stroke-width="1.8"></circle></svg
>Request Lab</a
>
<a class="preview-nav__link" href="./incident-log.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 4 21 20H3L12 4Z"
stroke-width="1.8"
stroke-linejoin="round"
></path>
<path
d="M12 9v5M12 17h.01"
stroke-width="1.8"
stroke-linecap="round"
></path></svg
>Incident Log</a
>
</nav>
<div class="preview-shell">
<aside class="preview-sidebar" aria-label="Operations sections">
<div class="preview-sidebar__section">Workspace</div>
<a class="preview-sidebar__item" href="./command-center.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 13h6V4H4v9ZM14 20h6v-9h-6v9ZM14 8h6V4h-6v4ZM4 20h6v-3H4v3Z"
stroke-width="1.8"
stroke-linejoin="round"
></path></svg
>Command Center</a
>
<a class="preview-sidebar__item" href="./service-catalog.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M7 4h10v4H7zM4 10h16v10H4zM8 14h8M8 17h5"
stroke-width="1.8"
stroke-linejoin="round"
stroke-linecap="round"
></path></svg
>Service Catalog</a
>
<a
class="preview-sidebar__item preview-sidebar__item--active"
href="#"
aria-current="page"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 5h14v14H5zM8 9h8M8 13h5M8 17h8"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Request Lab</a
>
<a class="preview-sidebar__item" href="./incident-log.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 4 21 20H3L12 4Z"
stroke-width="1.8"
stroke-linejoin="round"
></path>
<path
d="M12 9v5M12 17h.01"
stroke-width="1.8"
stroke-linecap="round"
></path></svg
>Incident Log</a
>
<div class="preview-sidebar__section">Tools</div>
<a class="preview-sidebar__item" href="#"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8"
stroke-width="1.8"
stroke-linecap="round"
></path>
<circle cx="12" cy="12" r="3.5" stroke-width="1.8"></circle></svg
>Environment</a
>
</aside>
<main class="preview-main">
<div class="preview-section-title">
<div>
<div class="preview-section-title__h">
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 5h16v14H4zM8 9h8M8 13h5"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
Request Lab
</div>
<div class="preview-section-title__hint">
Run a bounded request against an approved upstream route and
inspect timing.
</div>
</div>
<span class="preview-badge"
><span class="preview-badge__dot"></span>Authenticated
session</span
>
</div>
<div class="preview-split">
<form class="preview-form" aria-label="Request builder">
<div class="preview-section-title">
<div class="preview-section-title__h">
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 6h14M5 12h9M5 18h14"
stroke-width="1.8"
stroke-linecap="round"
></path>
<circle cx="17" cy="12" r="2" stroke-width="1.8"></circle>
</svg>
Request builder
</div>
<span class="preview-section-title__hint"
>Allowlisted routes only</span
>
</div>
<div class="preview-form__field">
<label class="preview-form__label" for="preset">Preset</label>
<select class="preview-form__select" id="preset">
<option>
Transit API GET /schedules/overview Schedule summary
</option>
<option>Transit API GET /admin/drivers Driver roster</option>
<option>Employee API GET /health Service health</option>
</select>
</div>
<div class="preview-two-column">
<div class="preview-form__field">
<label class="preview-form__label" for="service"
>Service</label
>
<select class="preview-form__select" id="service">
<option>Transit API</option>
<option>Employee API</option>
<option>Auth API</option>
</select>
</div>
<div class="preview-form__field">
<label class="preview-form__label" for="method">Method</label>
<select class="preview-form__select" id="method">
<option>GET</option>
<option>POST</option>
</select>
</div>
</div>
<div class="preview-form__field">
<label class="preview-form__label" for="path">Path</label>
<input
class="preview-form__input"
id="path"
type="text"
value="/schedules/overview"
/>
</div>
<div class="preview-two-column">
<div class="preview-form__field">
<label class="preview-form__label" for="timeout"
>Timeout</label
>
<input
class="preview-form__input"
id="timeout"
type="number"
value="5000"
/>
<span class="preview-form__hint">Milliseconds</span>
</div>
<div class="preview-form__field">
<label class="preview-form__label" for="query"
>Query fields</label
>
<input
class="preview-form__input"
id="query"
type="text"
value="date=2026-09-20"
/>
</div>
</div>
<div class="preview-form__field">
<label class="preview-form__label" for="body">Body</label>
<textarea
class="preview-form__textarea"
id="body"
placeholder="No request body for this GET route"
></textarea>
<span class="preview-form__hint"
>Safe limit: 10 requests per minute, 5-second timeout.</span
>
</div>
<section class="preview-error" aria-label="Request response">
<svg
class="preview-icon preview-icon--lg preview-error__icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 4 21 20H3L12 4Z"
stroke-width="1.8"
stroke-linejoin="round"
></path>
<path
d="M12 9v5M12 17h.01"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<div class="preview-error__body">
<div class="preview-error__title">Request blocked</div>
<div class="preview-error__text">
Admin role required for /admin/drivers
</div>
<button
class="preview-btn preview-btn--secondary"
type="button"
>
Retry request
</button>
</div>
</section>
</form>
<section class="preview-table-wrap" aria-labelledby="history-title">
<div
class="preview-section-title"
style="padding: 20px 20px 12px"
>
<div class="preview-section-title__h" id="history-title">
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 5h16v15H4zM8 9h8M8 13h5M8 17h8"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
Request history
</div>
<span class="preview-section-title__hint"
>Today, 09:00–12:00 UTC</span
>
</div>
<table class="preview-table">
<thead>
<tr>
<th>Request</th>
<th>Status</th>
<th>Duration</th>
<th>Time</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>GET</strong> /schedules/overview</td>
<td>
<span class="preview-badge preview-badge--success"
>200 OK</span
>
</td>
<td>214 ms</td>
<td>11:42:18</td>
</tr>
<tr>
<td><strong>GET</strong> /admin/drivers</td>
<td>
<span class="preview-badge preview-badge--danger"
>403</span
>
</td>
<td>88 ms</td>
<td>11:36:02</td>
</tr>
<tr>
<td><strong>GET</strong> /health</td>
<td>
<span class="preview-badge preview-badge--success"
>200 OK</span
>
</td>
<td>42 ms</td>
<td>11:20:44</td>
</tr>
<tr>
<td><strong>GET</strong> /schedules/overview</td>
<td>
<span class="preview-badge preview-badge--success"
>200 OK</span
>
</td>
<td>197 ms</td>
<td>10:58:09</td>
</tr>
<tr>
<td><strong>GET</strong> /admin/drivers</td>
<td>
<span class="preview-badge preview-badge--danger"
>403</span
>
</td>
<td>91 ms</td>
<td>10:41:27</td>
</tr>
</tbody>
</table>
</section>
</div>
<div class="preview-action-bar">
<span class="preview-form__hint"
>Requests are logged to the operations audit trail.</span
>
<button class="preview-btn preview-btn--secondary" type="button">
Clear
</button>
<button class="preview-btn preview-btn--primary" type="submit">
<svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="m5 12 5 5L20 7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Run request
</button>
</div>
</main>
</div>
</div>
</body>
</html>
+608
View File
@@ -0,0 +1,608 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Service Catalog | Transit Operations</title>
<link rel="stylesheet" href="./theme.css" />
</head>
<body>
<div class="preview-root">
<header class="preview-header">
<div class="preview-header__brand">
<div class="preview-header__logo" aria-hidden="true">TO</div>
<span>Transit Operations</span>
</div>
<div class="preview-header__actions">
<label
class="preview-form__input"
style="
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
min-width: 220px;
"
aria-label="Search services"
>
<svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="11" cy="11" r="6.5" stroke-width="1.8"></circle>
<path
d="m16 16 4.5 4.5"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<input
type="search"
placeholder="Search services"
style="
border: 0;
outline: 0;
background: transparent;
color: inherit;
width: 100%;
font: inherit;
"
/>
</label>
<a class="preview-header__action" href="#" aria-label="Notifications">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M18 9a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
</a>
<div class="preview-avatar" aria-label="Signed in as Olivia Moore">
OM
</div>
</div>
</header>
<nav class="preview-nav" aria-label="Primary navigation">
<a class="preview-nav__link" href="#"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 13h6V4H4v9Zm10 7h6v-9h-6v9ZM4 20h6v-3H4v3Zm10-12h6V4h-6v4Z"
stroke-width="1.7"
stroke-linejoin="round"
></path></svg
>Command Center</a
>
<a
class="preview-nav__link preview-nav__link--active"
href="./service-catalog.html"
aria-current="page"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 5h14v14H5zM8 9h8M8 13h5M8 17h3"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Service Catalog</a
>
<a class="preview-nav__link" href="./request-lab.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Request Lab</a
>
<a class="preview-nav__link" href="./incident-log.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="m12 4 8 16H4L12 4Zm0 5v5M12 17v.5"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Incident Log</a
>
</nav>
<div class="preview-shell">
<aside class="preview-sidebar" aria-label="Service catalog navigation">
<div class="preview-sidebar__section">Monitor</div>
<a class="preview-sidebar__item" href="#"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 13h6V4H4v9Zm10 7h6v-9h-6v9ZM4 20h6v-3H4v3Zm10-12h6V4h-6v4Z"
stroke-width="1.7"
stroke-linejoin="round"
></path></svg
>Command Center</a
>
<a
class="preview-sidebar__item preview-sidebar__item--active"
href="./service-catalog.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 5h14v14H5zM8 9h8M8 13h5M8 17h3"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Service Catalog</a
>
<a class="preview-sidebar__item" href="./request-lab.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Request Lab</a
>
<a class="preview-sidebar__item" href="./incident-log.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="m12 4 8 16H4L12 4Zm0 5v5M12 17v.5"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Incident Log</a
>
<div class="preview-sidebar__section">Workspace</div>
<a class="preview-sidebar__item" href="#"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 6h16M4 12h16M4 18h16"
stroke-width="1.7"
stroke-linecap="round"
></path></svg
>Route mappings</a
>
<a class="preview-sidebar__item" href="#"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 3 4 7v5c0 4.5 3.4 7.6 8 9 4.6-1.4 8-4.5 8-9V7l-8-4Zm0 5v4m0 4v.5"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Access policies</a
>
</aside>
<main class="preview-main">
<div class="preview-section-title">
<div>
<div
class="preview-section-title__h"
style="display: flex; align-items: center; gap: 8px"
>
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M6 4h12v16H6zM9 8h6M9 12h6M9 16h3"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Service Catalog
</div>
<p class="preview-card__body">
Inspect upstream capabilities and representative route coverage
across the transit platform.
</p>
</div>
<span class="preview-badge preview-badge--success"
><span class="preview-badge__dot"></span>5 services
monitored</span
>
</div>
<div class="preview-tabs" role="tablist" aria-label="Catalog views">
<a
class="preview-tabs__tab preview-tabs__tab--active"
href="#catalog"
role="tab"
aria-selected="true"
>Catalog</a
>
<a
class="preview-tabs__tab"
href="#unmapped"
role="tab"
aria-selected="false"
>Unmapped routes <span class="preview-badge">0</span></a
>
</div>
<section id="catalog" aria-label="Service catalog">
<div class="preview-section-title" style="margin-bottom: 12px">
<h2
class="preview-section-title__h"
style="display: flex; align-items: center; gap: 8px"
>
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 6h16v12H4zM8 10h8M8 14h5"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Monitored services
</h2>
<span class="preview-section-title__hint"
>Last inventory sync 09:42 UTC</span
>
</div>
<div class="preview-card-list">
<article class="preview-card">
<div class="preview-section-title">
<h3
class="preview-card__title"
style="display: flex; align-items: center; gap: 8px"
>
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 5h14v14H5zM8 9h8M8 13h5M8 17h3"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Auth API
</h3>
<span class="preview-badge preview-badge--success"
>Healthy</span
>
</div>
<p class="preview-card__body">
Identity and session access for operations staff.
</p>
<p class="preview-card__meta">4 endpoints · 2 route mappings</p>
</article>
<article class="preview-card">
<div class="preview-section-title">
<h3
class="preview-card__title"
style="display: flex; align-items: center; gap: 8px"
>
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 8a7 7 0 0 1 14 0"
stroke-width="1.7"
stroke-linecap="round"
></path></svg
>Employee API
</h3>
<span class="preview-badge preview-badge--success"
>Healthy</span
>
</div>
<p class="preview-card__body">
Staff records, roles, and workforce administration.
</p>
<p class="preview-card__meta">7 endpoints · 1 route mapping</p>
</article>
<article class="preview-card">
<div class="preview-section-title">
<h3
class="preview-card__title"
style="display: flex; align-items: center; gap: 8px"
>
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 17h16M5 17V8h14v9M8 8V5h8v3M8 20h8"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Transit API
</h3>
<span class="preview-badge preview-badge--success"
>Healthy</span
>
</div>
<p class="preview-card__body">
Routes, stops, fleet data, and daily schedules.
</p>
<p class="preview-card__meta">
18 endpoints · 2 route mappings
</p>
</article>
<article class="preview-card">
<div class="preview-section-title">
<h3
class="preview-card__title"
style="display: flex; align-items: center; gap: 8px"
>
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 4h14v16H5zM8 8h8M8 12h5M8 16h3"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Error Log API
</h3>
<span class="preview-badge preview-badge--warning"
>Degraded</span
>
</div>
<p class="preview-card__body">
Centralized application errors and diagnostic events.
</p>
<p class="preview-card__meta">3 endpoints · 1 route mapping</p>
</article>
<article class="preview-card">
<div class="preview-section-title">
<h3
class="preview-card__title"
style="display: flex; align-items: center; gap: 8px"
>
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 5h16v14H4zM8 9h8M8 13h5M8 17h3"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Admin UI
</h3>
<span class="preview-badge preview-badge--success"
>Healthy</span
>
</div>
<p class="preview-card__body">
Signed-in administration workspace for transit teams.
</p>
<p class="preview-card__meta">12 screens · 0 route mappings</p>
</article>
</div>
</section>
<section aria-labelledby="coverage-title">
<div class="preview-section-title" style="margin-bottom: 12px">
<h2
id="coverage-title"
class="preview-section-title__h"
style="display: flex; align-items: center; gap: 8px"
>
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 5h14v14H5zM8 9h8M8 13h8M8 17h4"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Representative route coverage
</h2>
<span class="preview-section-title__hint"
>4 mapped capabilities</span
>
</div>
<div class="preview-table-wrap">
<table class="preview-table">
<thead>
<tr>
<th>Service</th>
<th>Capability</th>
<th>Route</th>
<th>Access</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Transit API</strong></td>
<td>Daily schedules</td>
<td><code>/schedule/daily</code></td>
<td><span class="preview-badge">Authenticated</span></td>
<td>
<span class="preview-badge preview-badge--success"
>Mapped</span
>
</td>
</tr>
<tr>
<td><strong>Transit API</strong></td>
<td>Route detail</td>
<td><code>/routes/:routeId</code></td>
<td><span class="preview-badge">Authenticated</span></td>
<td>
<span class="preview-badge preview-badge--success"
>Mapped</span
>
</td>
</tr>
<tr>
<td><strong>Employee API</strong></td>
<td>Create employee</td>
<td><code>/createemployee</code></td>
<td>
<span class="preview-badge preview-badge--warning"
>Admin</span
>
</td>
<td>
<span class="preview-badge preview-badge--success"
>Mapped</span
>
</td>
</tr>
<tr>
<td><strong>Error Log API</strong></td>
<td>Read error log</td>
<td><code>/error_log</code></td>
<td>
<span class="preview-badge preview-badge--warning"
>Admin</span
>
</td>
<td>
<span class="preview-badge preview-badge--success"
>Mapped</span
>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section
id="unmapped"
class="preview-empty"
aria-label="Unmapped routes empty state"
>
<div class="preview-empty__icon">
<svg
class="preview-icon preview-icon--lg"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 5h11v14H4zM8 9h3M8 13h3M17 15l4 4M15 13a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
</div>
<div class="preview-empty__title">No unmapped routes</div>
<div class="preview-empty__body">
All discovered upstream capabilities are currently represented in
the catalog.
</div>
<a class="preview-btn preview-btn--primary" href="#"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 5v14M5 12h14"
stroke-width="2"
stroke-linecap="round"
></path></svg
>Add route mapping</a
>
</section>
<div class="preview-action-bar">
<span class="preview-section-title__hint"
>Catalog source: route inventory and service manifests</span
>
<a class="preview-btn preview-btn--secondary" href="#"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 4v11m0 0 4-4m-4 4-4-4M5 19h14"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Export catalog</a
>
<a class="preview-btn preview-btn--primary" href="#"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 5v14M5 12h14"
stroke-width="2"
stroke-linecap="round"
></path></svg
>Add route mapping</a
>
</div>
</main>
</div>
<footer class="preview-footer">
<span>Transit Operations Console</span
><span>Environment: Production · Read-only catalog</span>
</footer>
</div>
</body>
</html>
+592
View File
@@ -0,0 +1,592 @@
:root {
--color-primary: #155eef;
--color-on-primary: #ffffff;
--color-accent: #f59e0b;
--color-on-accent: #172033;
--color-surface: #f6f8fb;
--color-surface-raised: #ffffff;
--color-surface-sunken: #eef2f7;
--color-text: #172033;
--color-muted: #667085;
--color-border: #d9e0ea;
--color-success: #16803c;
--color-warning: #b54708;
--color-danger: #d92d20;
--font-body: "Segoe UI Variable", "Segoe UI", sans-serif;
--font-heading: var(--font-body);
--text-xs: 11px;
--text-sm: 13px;
--text-base: 14px;
--text-lg: 17px;
--text-xl: 22px;
--text-2xl: 30px;
--text-3xl: 38px;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-pill: 9999px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 28px;
--space-7: 40px;
--space-8: 56px;
--shadow-sm: 0 1px 2px rgba(23, 32, 51, 0.06);
--shadow-md: 0 5px 16px rgba(23, 32, 51, 0.1);
--shadow-lg: 0 16px 32px rgba(23, 32, 51, 0.14);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-body);
font-size: var(--text-base);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
h1,
h2,
h3 {
font-family: var(--font-heading);
line-height: 1.2;
margin: 0;
}
a {
color: var(--color-primary);
text-decoration: none;
}
.preview-root {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.preview-shell {
display: flex;
flex: 1;
min-height: 0;
}
.preview-main {
flex: 1;
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-5);
min-width: 0;
}
.preview-split,
.preview-two-column {
display: grid;
gap: var(--space-5);
}
.preview-split {
grid-template-columns: 1fr 2fr;
}
.preview-two-column {
grid-template-columns: 1fr 1fr;
}
.preview-header {
position: sticky;
top: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-3) var(--space-6);
background: var(--color-surface-raised);
border-bottom: 1px solid var(--color-border);
box-shadow: var(--shadow-sm);
}
.preview-header__brand,
.preview-header__actions {
display: flex;
align-items: center;
gap: var(--space-3);
}
.preview-header__brand {
font-weight: 700;
}
.preview-header__logo {
width: 30px;
height: 30px;
border-radius: var(--radius-sm);
background: var(--color-primary);
color: var(--color-on-primary);
display: grid;
place-items: center;
font-size: var(--text-sm);
}
.preview-header__action {
color: var(--color-muted);
display: inline-flex;
}
.preview-nav {
display: flex;
gap: var(--space-1);
padding: var(--space-2) var(--space-6);
background: var(--color-surface-raised);
border-bottom: 1px solid var(--color-border);
}
.preview-nav__link,
.preview-sidebar__item {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--color-muted);
border-radius: var(--radius-sm);
}
.preview-nav__link {
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
}
.preview-nav__link--active,
.preview-sidebar__item--active {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
font-weight: 600;
}
.preview-sidebar {
width: 220px;
flex-shrink: 0;
padding: var(--space-4) var(--space-3);
background: var(--color-surface-sunken);
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.preview-sidebar__section {
padding: var(--space-3) var(--space-2) var(--space-1);
color: var(--color-muted);
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.preview-sidebar__item {
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
}
.preview-hero {
position: relative;
overflow: hidden;
padding: var(--space-6);
border-radius: var(--radius-md);
background: linear-gradient(135deg, #155eef, #174ea6);
color: var(--color-on-primary);
display: flex;
flex-direction: column;
gap: var(--space-3);
box-shadow: var(--shadow-md);
}
.preview-hero__mesh {
position: absolute;
inset: 0;
opacity: 0.35;
background:
radial-gradient(60% 80% at 100% 0%, #7aa7ff, transparent 70%),
radial-gradient(60% 80% at 0% 100%, #0e3b8e, transparent 70%);
}
.preview-hero > *:not(.preview-hero__mesh) {
position: relative;
z-index: 1;
}
.preview-hero__eyebrow {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: var(--text-xs);
font-weight: 700;
opacity: 0.85;
}
.preview-hero__title {
font-size: var(--text-2xl);
font-weight: 700;
max-width: 28ch;
}
.preview-hero__subtitle {
max-width: 62ch;
opacity: 0.9;
}
.preview-hero__actions,
.preview-action-bar {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.preview-kpi-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--space-3);
}
.preview-kpi,
.preview-card,
.preview-form,
.preview-empty,
.preview-table-wrap {
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.preview-kpi {
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.preview-kpi__label {
color: var(--color-muted);
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.preview-kpi__value {
font-size: var(--text-xl);
font-weight: 700;
}
.preview-kpi__delta {
color: var(--color-muted);
font-size: var(--text-xs);
}
.preview-kpi__delta--up {
color: var(--color-success);
font-weight: 600;
}
.preview-section-title {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-3);
}
.preview-section-title__h {
font-size: var(--text-lg);
font-weight: 700;
}
.preview-section-title__hint {
color: var(--color-muted);
font-size: var(--text-xs);
}
.preview-card-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.preview-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--space-4);
}
.preview-card {
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
transition:
transform 160ms ease,
box-shadow 160ms ease;
}
.preview-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.preview-card__title {
font-weight: 600;
}
.preview-card__body,
.preview-card__meta {
margin: 0;
color: var(--color-muted);
font-size: var(--text-sm);
}
.preview-card__meta {
font-size: var(--text-xs);
}
.preview-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 2px var(--space-2);
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
font-size: var(--text-xs);
font-weight: 600;
}
.preview-badge--success {
background: #e7f6ec;
color: var(--color-success);
}
.preview-badge--warning {
background: #fff4e5;
color: var(--color-warning);
}
.preview-badge--danger {
background: #feeceb;
color: var(--color-danger);
}
.preview-badge__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.preview-form {
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.preview-form__field {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.preview-form__label {
font-size: var(--text-sm);
font-weight: 600;
}
.preview-form__hint {
color: var(--color-muted);
font-size: var(--text-xs);
}
.preview-form__input,
.preview-form__textarea,
.preview-form__select {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
}
.preview-form__textarea {
min-height: 96px;
resize: vertical;
}
.preview-form__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
}
.preview-btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border: 1px solid transparent;
border-radius: var(--radius-sm);
font: inherit;
font-size: var(--text-sm);
font-weight: 600;
}
.preview-btn--primary {
background: var(--color-primary);
color: var(--color-on-primary);
box-shadow: var(--shadow-sm);
}
.preview-btn--secondary {
background: var(--color-surface);
color: var(--color-text);
border-color: var(--color-border);
}
.preview-btn--ghost {
background: transparent;
color: var(--color-text);
}
.preview-table-wrap {
overflow: hidden;
}
.preview-table {
width: 100%;
border-collapse: collapse;
}
.preview-table th,
.preview-table td {
text-align: left;
padding: var(--space-3);
border-bottom: 1px solid var(--color-border);
font-size: var(--text-sm);
}
.preview-table th {
background: var(--color-surface-sunken);
color: var(--color-muted);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.preview-table tr:last-child td {
border-bottom: 0;
}
.preview-action-bar {
justify-content: flex-end;
padding-top: var(--space-3);
border-top: 1px solid var(--color-border);
}
.preview-tabs {
display: flex;
gap: var(--space-1);
border-bottom: 1px solid var(--color-border);
}
.preview-tabs__tab {
padding: var(--space-2) var(--space-4);
color: var(--color-muted);
font-size: var(--text-sm);
}
.preview-tabs__tab--active {
color: var(--color-primary);
border-bottom: 2px solid var(--color-primary);
font-weight: 600;
}
.preview-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
padding: var(--space-7) var(--space-4);
text-align: center;
}
.preview-empty__icon {
width: 58px;
height: 58px;
display: grid;
place-items: center;
border-radius: var(--radius-pill);
background: #e6eeff;
color: var(--color-primary);
}
.preview-empty__title {
font-weight: 700;
}
.preview-empty__body {
max-width: 40ch;
color: var(--color-muted);
font-size: var(--text-sm);
}
.preview-error {
display: flex;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid #f4b8b4;
border-radius: var(--radius-md);
background: #fff4f3;
}
.preview-error__icon {
color: var(--color-danger);
}
.preview-error__body {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.preview-error__title {
font-size: var(--text-sm);
font-weight: 700;
}
.preview-error__text {
color: var(--color-muted);
font-size: var(--text-sm);
}
.preview-footer {
display: flex;
justify-content: space-between;
padding: var(--space-3) var(--space-6);
color: var(--color-muted);
font-size: var(--text-xs);
border-top: 1px solid var(--color-border);
}
.preview-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
display: inline-block;
vertical-align: middle;
stroke: currentColor;
fill: none;
}
.preview-icon--sm {
width: 14px;
height: 14px;
}
.preview-icon--lg {
width: 24px;
height: 24px;
}
.preview-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
display: grid;
place-items: center;
color: var(--color-primary);
background: #e6eeff;
font-size: var(--text-xs);
font-weight: 700;
}
.preview-skeleton {
background: linear-gradient(90deg, #e8edf4 25%, #f7f9fc 37%, #e8edf4 63%);
background-size: 400% 100%;
border-radius: var(--radius-sm);
animation: preview-shimmer 1.4s ease infinite;
}
.preview-skeleton--line {
height: 12px;
margin-bottom: var(--space-2);
}
.preview-skeleton--title {
height: 18px;
width: 40%;
margin-bottom: var(--space-3);
}
.preview-skeleton--block {
height: 96px;
}
@keyframes preview-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: 0 0;
}
}
@media (max-width: 760px) {
.preview-sidebar {
display: none;
}
.preview-main {
padding: var(--space-4);
}
.preview-nav {
overflow-x: auto;
padding: var(--space-2) var(--space-4);
}
.preview-split,
.preview-two-column {
grid-template-columns: 1fr;
}
.preview-header {
padding: var(--space-3) var(--space-4);
}
.preview-table-wrap {
overflow-x: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.preview-card,
.preview-skeleton {
transition: none;
animation: none;
}
}
+247
View File
@@ -0,0 +1,247 @@
{
"schemaVersion": "2",
"generatedAt": "2026-09-20T00:00:00.000Z",
"mode": "NEW",
"summary": "A new React + Vite monitoring dashboard for the existing XTransit APIs and admin UI. It will aggregate health, service capabilities, error activity, and a guarded API request tester without adding a new datastore.",
"workspaceSignals": {
"detectedFiles": [
"README.md",
"docker-compose.yml",
"auth_api/index.js",
"auth_api/package.json",
"employee_api/index.js",
"employee_api/package.json",
"transit_api/index.js",
"transit_api/package.json",
"error_log_api/index.js",
"error_log_api/package.json",
"admin_ui/package.json",
"admin_ui/src/api.js",
"admin_ui/src/App.jsx",
"DOMAINS_README.md",
"admin_ui/README.md",
"admin_ui/SCHEDULE_API.md",
"transit_api/README.md"
],
"decision": "NEW",
"decisionReason": "The repository contains the existing services and admin UI, but no monitoring dashboard; the requested dashboard is a new project folder within this workspace."
},
"services": [
{
"id": "transit-monitor-dashboard",
"label": "Transit Monitor Dashboard",
"role": "frontend",
"root": "./monitoring_dashboard"
},
{
"id": "monitoring-proxy-api",
"label": "Monitoring Proxy API",
"role": "backend",
"root": "./monitoring_dashboard/server"
}
],
"questions": [
{
"id": "transit-monitor-dashboard:language",
"category": "service",
"question": "Which programming language for Transit Monitor Dashboard?",
"header": "Language",
"answer": "TypeScript",
"status": "confirmed",
"rationale": "The requested framework is explicitly React + Vite, but the language was not specified; TypeScript is recommended for the dashboard's endpoint catalog and response-shape handling.",
"options": [
{
"label": "TypeScript",
"description": "Type-safe React application code"
},
{
"label": "JavaScript",
"description": "Plain JavaScript matching the existing admin UI"
}
],
"recommendedChoice": "TypeScript",
"multiSelect": false,
"allowFreeformInput": false,
"serviceId": "transit-monitor-dashboard"
},
{
"id": "transit-monitor-dashboard:framework",
"category": "service",
"question": "Which frontend framework for Transit Monitor Dashboard?",
"header": "Framework",
"answer": "React + Vite",
"status": "confirmed",
"rationale": "React + Vite is explicitly requested.",
"options": [
{
"label": "React + Vite",
"description": "React SPA with Vite, as requested"
},
{
"label": "Vue + Vite",
"description": "Vue SPA with Vite"
},
{
"label": "Angular",
"description": "Angular application"
},
{
"label": "Svelte",
"description": "Svelte application with Vite"
}
],
"recommendedChoice": "React + Vite",
"multiSelect": false,
"allowFreeformInput": true,
"serviceId": "transit-monitor-dashboard"
},
{
"id": "transit-monitor-dashboard:features",
"category": "service",
"question": "Describe the features or API routes for Transit Monitor Dashboard.",
"header": "Features",
"answer": "Service health overview for auth_api, employee_api, transit_api, error_log_api, and admin_ui; service capability catalog; error and bad-request views; guarded live request tester; configurable environments; polling controls; response timing and status history.",
"status": "confirmed",
"rationale": "Distilled from the requested monitoring, service-data, error, request-testing, and health-check workflows.",
"recommendedChoice": "Service health overview for auth_api, employee_api, transit_api, error_log_api, and admin_ui; service capability catalog; error and bad-request views; guarded live request tester; configurable environments; polling controls; response timing and status history.",
"multiSelect": false,
"serviceId": "transit-monitor-dashboard"
},
{
"id": "monitoring-proxy-api:language",
"category": "service",
"question": "Which programming language for Monitoring Proxy API?",
"header": "Language",
"answer": "TypeScript",
"status": "confirmed",
"rationale": "The workspace already uses Node.js services and the proxy needs shared typed endpoint metadata and response normalization; TypeScript is recommended, but the user did not explicitly choose the proxy language.",
"options": [
{
"label": "TypeScript",
"description": "Node.js + TypeScript proxy service"
},
{
"label": "Python",
"description": "Python proxy service"
},
{
"label": "C# (.NET)",
"description": ".NET proxy service"
}
],
"recommendedChoice": "TypeScript",
"multiSelect": false,
"allowFreeformInput": false,
"serviceId": "monitoring-proxy-api"
},
{
"id": "monitoring-proxy-api:framework",
"category": "service",
"question": "Which backend framework for Monitoring Proxy API?",
"header": "Framework",
"answer": "Express",
"status": "confirmed",
"rationale": "Express matches the existing API services and is sufficient for an allowlisted proxy, health aggregation, and request execution endpoints.",
"options": [
{
"label": "Express",
"description": "Small Node.js HTTP proxy consistent with the existing APIs"
},
{
"label": "Fastify",
"description": "Typed, low-overhead Node.js HTTP service"
},
{
"label": "Azure Functions",
"description": "Serverless HTTP functions"
}
],
"recommendedChoice": "Express",
"multiSelect": false,
"allowFreeformInput": true,
"serviceId": "monitoring-proxy-api"
},
{
"id": "monitoring-proxy-api:features",
"category": "service",
"question": "Describe the features or API routes for Monitoring Proxy API.",
"header": "Features",
"answer": "Environment-configured upstream base URLs; aggregated health checks including admin_ui; allowlisted endpoint catalog; authenticated request execution with safe method/body/timeout limits; error-log read proxy; redacted structured results; CORS and rate limiting.",
"status": "confirmed",
"rationale": "The proxy is planned to centralize cross-origin access and protect administrative/tester capabilities while preserving the existing APIs unchanged.",
"recommendedChoice": "Environment-configured upstream base URLs; aggregated health checks including admin_ui; allowlisted endpoint catalog; authenticated request execution with safe method/body/timeout limits; error-log read proxy; redacted structured results; CORS and rate limiting.",
"multiSelect": false,
"serviceId": "monitoring-proxy-api"
},
{
"id": "dataStores",
"category": "data",
"question": "Which data stores does your app need?",
"header": "Data Stores",
"answer": [
"No datastore required"
],
"status": "confirmed",
"rationale": "The dashboard monitors and queries the existing APIs and error-log database; it needs only bounded in-memory polling state and does not introduce persistence, file storage, queues, or caches.",
"options": [
{
"label": "No datastore required",
"description": "The dashboard reads existing APIs and does not persist new data",
"exclusive": true
},
{
"label": "Blob Storage",
"description": "Store files and images"
},
{
"label": "Queue Storage",
"description": "Async message queue"
},
{
"label": "PostgreSQL",
"description": "Relational database"
},
{
"label": "CosmosDB",
"description": "NoSQL document database"
},
{
"label": "Redis",
"description": "In-memory cache"
},
{
"label": "Azure SQL",
"description": "Managed SQL Server"
}
],
"recommendedChoice": [
"No datastore required"
],
"multiSelect": true,
"allowFreeformInput": false
},
{
"id": "auth",
"category": "auth",
"question": "Does this app need user login?",
"header": "API Login",
"answer": "Yes",
"status": "confirmed",
"rationale": "Reading centralized errors and exercising authenticated transit/admin endpoints must be restricted to authorized dashboard users.",
"options": [
{
"label": "Yes",
"description": "Add a user sign-in flow and authenticated API access"
},
{
"label": "No",
"description": "No user sign-in or authenticated API access"
}
],
"recommendedChoice": "Yes",
"multiSelect": false,
"allowFreeformInput": false
}
],
"executionMode": "guided"
}
+90 -15
View File
@@ -4,14 +4,19 @@ on:
push: push:
branches: branches:
- dev - dev
- main
- develop
- auth-api - auth-api
- employee-api - employee-api
- transit-api - transit-api
- error-log-api - error-log-api
- admin-ui - admin-ui
- monitoring-ui
pull_request: pull_request:
branches: branches:
- dev - dev
- main
- develop
env: env:
REGISTRY: gitea.testinstance.nl REGISTRY: gitea.testinstance.nl
@@ -26,9 +31,12 @@ jobs:
transit_api: ${{ steps.filter.outputs.transit_api }} transit_api: ${{ steps.filter.outputs.transit_api }}
error_log_api: ${{ steps.filter.outputs.error_log_api }} error_log_api: ${{ steps.filter.outputs.error_log_api }}
admin_ui: ${{ steps.filter.outputs.admin_ui }} admin_ui: ${{ steps.filter.outputs.admin_ui }}
monitoring_ui: ${{ steps.filter.outputs.monitoring_ui }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect changed paths - name: Detect changed paths
id: filter id: filter
@@ -55,10 +63,14 @@ jobs:
- 'admin_ui/**' - 'admin_ui/**'
- 'docker-compose.yml' - 'docker-compose.yml'
- '.gitea/workflows/docker-build.yml' - '.gitea/workflows/docker-build.yml'
monitoring_ui:
- 'monitoring_ui/**'
- 'docker-compose.yml'
- '.gitea/workflows/docker-build.yml'
build-auth-api: build-auth-api:
needs: detect-changes needs: detect-changes
if: ${{ (gitea.ref_name == 'auth-api' || gitea.ref_name == 'dev') && needs.detect-changes.outputs.auth_api == 'true' }} if: ${{ (gitea.ref_name == 'auth-api' || gitea.ref_name == 'dev' || gitea.ref_name == 'main' || gitea.ref_name == 'develop') && needs.detect-changes.outputs.auth_api == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
@@ -82,6 +94,9 @@ jobs:
echo "tags<<EOF" >> "$GITHUB_OUTPUT" echo "tags<<EOF" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.ref_name }}" >> "$GITHUB_OUTPUT" echo "${image_base}:${{ gitea.ref_name }}" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.sha }}" >> "$GITHUB_OUTPUT" echo "${image_base}:${{ gitea.sha }}" >> "$GITHUB_OUTPUT"
if [[ "${{ gitea.ref_name }}" == "dev" || "${{ gitea.ref_name }}" == "main" || "${{ gitea.ref_name }}" == "develop" ]]; then
echo "${image_base}:latest" >> "$GITHUB_OUTPUT"
fi
echo "EOF" >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT"
- name: Build and push auth_api - name: Build and push auth_api
@@ -93,7 +108,7 @@ jobs:
build-employee-api: build-employee-api:
needs: detect-changes needs: detect-changes
if: ${{ (gitea.ref_name == 'employee-api' || gitea.ref_name == 'dev') && needs.detect-changes.outputs.employee_api == 'true' }} if: ${{ (gitea.ref_name == 'employee-api' || gitea.ref_name == 'dev' || gitea.ref_name == 'main' || gitea.ref_name == 'develop') && needs.detect-changes.outputs.employee_api == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
@@ -117,6 +132,9 @@ jobs:
echo "tags<<EOF" >> "$GITHUB_OUTPUT" echo "tags<<EOF" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.ref_name }}" >> "$GITHUB_OUTPUT" echo "${image_base}:${{ gitea.ref_name }}" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.sha }}" >> "$GITHUB_OUTPUT" echo "${image_base}:${{ gitea.sha }}" >> "$GITHUB_OUTPUT"
if [[ "${{ gitea.ref_name }}" == "dev" || "${{ gitea.ref_name }}" == "main" || "${{ gitea.ref_name }}" == "develop" ]]; then
echo "${image_base}:latest" >> "$GITHUB_OUTPUT"
fi
echo "EOF" >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT"
- name: Build and push employee_api - name: Build and push employee_api
@@ -128,7 +146,7 @@ jobs:
build-transit-api: build-transit-api:
needs: detect-changes needs: detect-changes
if: ${{ (gitea.ref_name == 'transit-api' || gitea.ref_name == 'dev') && needs.detect-changes.outputs.transit_api == 'true' }} if: ${{ (gitea.ref_name == 'transit-api' || gitea.ref_name == 'dev' || gitea.ref_name == 'main' || gitea.ref_name == 'develop') && needs.detect-changes.outputs.transit_api == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
@@ -152,6 +170,9 @@ jobs:
echo "tags<<EOF" >> "$GITHUB_OUTPUT" echo "tags<<EOF" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.ref_name }}" >> "$GITHUB_OUTPUT" echo "${image_base}:${{ gitea.ref_name }}" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.sha }}" >> "$GITHUB_OUTPUT" echo "${image_base}:${{ gitea.sha }}" >> "$GITHUB_OUTPUT"
if [[ "${{ gitea.ref_name }}" == "dev" || "${{ gitea.ref_name }}" == "main" || "${{ gitea.ref_name }}" == "develop" ]]; then
echo "${image_base}:latest" >> "$GITHUB_OUTPUT"
fi
echo "EOF" >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT"
- name: Build and push transit_api - name: Build and push transit_api
@@ -163,7 +184,7 @@ jobs:
build-error-log-api: build-error-log-api:
needs: detect-changes needs: detect-changes
if: ${{ (gitea.ref_name == 'error-log-api' || gitea.ref_name == 'dev') && needs.detect-changes.outputs.error_log_api == 'true' }} if: ${{ (gitea.ref_name == 'error-log-api' || gitea.ref_name == 'dev' || gitea.ref_name == 'main' || gitea.ref_name == 'develop') && needs.detect-changes.outputs.error_log_api == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
@@ -187,6 +208,9 @@ jobs:
echo "tags<<EOF" >> "$GITHUB_OUTPUT" echo "tags<<EOF" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.ref_name }}" >> "$GITHUB_OUTPUT" echo "${image_base}:${{ gitea.ref_name }}" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.sha }}" >> "$GITHUB_OUTPUT" echo "${image_base}:${{ gitea.sha }}" >> "$GITHUB_OUTPUT"
if [[ "${{ gitea.ref_name }}" == "dev" || "${{ gitea.ref_name }}" == "main" || "${{ gitea.ref_name }}" == "develop" ]]; then
echo "${image_base}:latest" >> "$GITHUB_OUTPUT"
fi
echo "EOF" >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT"
- name: Build and push error_log_api - name: Build and push error_log_api
@@ -198,7 +222,7 @@ jobs:
build-admin-ui: build-admin-ui:
needs: detect-changes needs: detect-changes
if: ${{ (gitea.ref_name == 'admin-ui' || gitea.ref_name == 'dev') && needs.detect-changes.outputs.admin_ui == 'true' }} if: ${{ (gitea.ref_name == 'admin-ui' || gitea.ref_name == 'dev' || gitea.ref_name == 'main' || gitea.ref_name == 'develop') && needs.detect-changes.outputs.admin_ui == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
@@ -222,6 +246,9 @@ jobs:
echo "tags<<EOF" >> "$GITHUB_OUTPUT" echo "tags<<EOF" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.ref_name }}" >> "$GITHUB_OUTPUT" echo "${image_base}:${{ gitea.ref_name }}" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.sha }}" >> "$GITHUB_OUTPUT" echo "${image_base}:${{ gitea.sha }}" >> "$GITHUB_OUTPUT"
if [[ "${{ gitea.ref_name }}" == "dev" || "${{ gitea.ref_name }}" == "main" || "${{ gitea.ref_name }}" == "develop" ]]; then
echo "${image_base}:latest" >> "$GITHUB_OUTPUT"
fi
echo "EOF" >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT"
- name: Build and push admin_ui - name: Build and push admin_ui
@@ -231,17 +258,65 @@ jobs:
push: ${{ gitea.event_name == 'push' }} push: ${{ gitea.event_name == 'push' }}
tags: ${{ steps.tags.outputs.tags }} tags: ${{ steps.tags.outputs.tags }}
build-monitoring-ui:
needs: detect-changes
if: ${{ (gitea.ref_name == 'monitoring-ui' || gitea.ref_name == 'dev' || gitea.ref_name == 'main' || gitea.ref_name == 'develop') && needs.detect-changes.outputs.monitoring_ui == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Set image tags
id: tags
shell: bash
run: |
image_base="${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/monitoring_ui"
echo "tags<<EOF" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.ref_name }}" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.sha }}" >> "$GITHUB_OUTPUT"
if [[ "${{ gitea.ref_name }}" == "dev" || "${{ gitea.ref_name }}" == "main" || "${{ gitea.ref_name }}" == "develop" ]]; then
echo "${image_base}:latest" >> "$GITHUB_OUTPUT"
fi
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Build and push monitoring_ui
uses: docker/build-push-action@v6
with:
context: ./monitoring_ui
push: ${{ gitea.event_name == 'push' }}
tags: ${{ steps.tags.outputs.tags }}
deploy-test-node: deploy-test-node:
needs: needs: [detect-changes, build-auth-api, build-employee-api, build-transit-api, build-error-log-api, build-admin-ui, build-monitoring-ui]
[ if: ${{ always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && gitea.event_name == 'push' }}
detect-changes, strategy:
build-auth-api, fail-fast: false
build-employee-api, matrix:
build-transit-api, include:
build-error-log-api, - name: auth-api
build-admin-ui, need_job: build-auth-api
] - name: employee-api
if: ${{ gitea.event_name == 'push' && gitea.ref_name == 'dev' }} need_job: build-employee-api
- name: transit-api
need_job: build-transit-api
- name: error-log-api
need_job: build-error-log-api
- name: admin-ui
need_job: build-admin-ui
- name: monitoring-ui
need_job: build-monitoring-ui
if: ${{ needs[matrix.value.need_job].result == 'success' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+14 -7
View File
@@ -10,6 +10,8 @@
node_modules/ node_modules/
admin_ui/node_modules/ admin_ui/node_modules/
admin_ui/dist/ admin_ui/dist/
monitoring_ui/node_modules/
monitoring_ui/dist/
coverage/ coverage/
dist/ dist/
build/ build/
@@ -44,12 +46,13 @@ dump.sql
.vscode/ .vscode/
.idea/ .idea/
# Sensitive project docs # Sensitive project docs & AI Agent Map
ADMIN_DEPLOYMENT_SUMMARY.md PROJECT_MAP.md
CI_CD_PORTAINER_GUIDE.md monitoring_ui/docs/ADMIN_DEPLOYMENT_SUMMARY.md
DEPLOYMENT.md monitoring_ui/docs/CI_CD_PORTAINER_GUIDE.md
GETTING_STARTED.md monitoring_ui/docs/DEPLOYMENT.md
handoff.md monitoring_ui/docs/GETTING_STARTED.md
monitoring_ui/docs/handoff.md
INTERNAL_*.md INTERNAL_*.md
SETUP_*.md SETUP_*.md
DEPLOY_*.md DEPLOY_*.md
@@ -67,8 +70,12 @@ scripts/private/
scripts/*.local.* scripts/*.local.*
scripts/.env scripts/.env
# Monitoring UI local configuration
monitoring_ui/.env.local
monitoring_ui/.env.*.local
# Project-specific private notes # Project-specific private notes
transit_api/LOCAL_BUS_TIMETABLE_README.md monitoring_ui/docs/transit_api/LOCAL_BUS_TIMETABLE_README.md
# Deprecated app backups and legacy artifacts # Deprecated app backups and legacy artifacts
deprecated/build/ deprecated/build/
-315
View File
@@ -1,315 +0,0 @@
# GitHub Actions → Docker Registry → Portainer Deployment
Automatisch Docker images bouwen via GitHub en deployen op Portainer.
## 📋 Setup Stappen
### 1. GitHub Repository voorbereiding
```bash
# Push je code naar GitHub (met .github/workflows/docker-build.yml)
git add .
git commit -m "Add Docker build workflow"
git push origin main
```
**Controleer:** https://github.com/YOUR_USERNAME/YOUR_REPO/actions
### 2. GitHub Container Registry inschakelen
✅ Automatisch als je private repo hebt. Voor public repos:
1. Ga naar GitHub repo → **Settings → Packages and registries**
2. Of gewoon eerste push doen → workflow doet dit automatisch
### 3. GitHub Token configureren (optioneel)
Standaard gebruikt GitHub Actions je `GITHUB_TOKEN` (automatisch).
Voor meer controle, maak Personal Access Token (PAT):
1. GitHub → **Settings → Developer settings → Personal access tokens**
2. New token → Scopes: `write:packages`, `read:packages`
3. Token kopieren
### 4. Docker Login op Portainer Server
```bash
# Op je remote server waar Portainer draait
docker login ghcr.io
# Username: je GitHub username
# Password: GITHUB_TOKEN of PAT token
```
---
## 🚀 Image Bouwen & Pushen
### Automatisch (via GitHub Actions)
1. **Je code pushen naar main/develop:**
```bash
git push origin main
```
2. **GitHub Actions bouwt automatisch:**
- ✅ Checkout code
- ✅ Build Docker images
- ✅ Push naar ghcr.io
- ✅ Tags: `latest`, branch name, git SHA
3. **Bekijk workflow:**
- GitHub → **Actions tab** → Zie voortgang
- Logs klikken voor debug info
### Images checken in registry
```bash
# List available images/tags
docker pull ghcr.io/YOUR_USERNAME/YOUR_REPO/auth_api:latest --dry-run
# of via GitHub CLI:
gh api user/packages --jq '.[] | select(.package_type=="container")'
```
---
## 🐳 Portainer Deployment
### Methode 1: Via Docker Compose (aanbevolen)
**Op je Portainer server:**
1. **Clone repo of kopie bestand:**
```bash
git clone https://github.com/YOUR_USERNAME/YOUR_REPO.git
cd YOUR_REPO
```
2. **Login bij registry:**
```bash
docker login ghcr.io
# Credentials: GitHub username + token
```
3. **Start via docker-compose:**
```bash
docker-compose -f docker-compose.portainer.yml up -d
```
### Methode 2: Via Portainer UI
1. **Portainer Web Dashboard → Stacks**
2. **"Add Stack" → "Compose"**
3. **Paste inhoud van `docker-compose.portainer.yml`**
4. **Voeg environment vars toe:**
- `POSTGRES_PASSWORD=...`
- `JWT_SECRET=...`
5. **Deploy**
### Methode 3: Private Registry in Portainer
1. **Portainer → Registries → Add Registry**
- **Provider:** GitHub Container Registry
- **URL:** `ghcr.io`
- **Username:** je GitHub username
- **Password:** GITHUB_TOKEN
2. **In docker-compose, images gebruiken:**
```yaml
image: ghcr.io/YOUR_USERNAME/YOUR_REPO/auth_api:latest
```
---
## 🔄 Workflow Overzicht
```
┌─────────────────┐
│ Push to GitHub │
└────────┬────────┘
│
▼
┌──────────────────────────────┐
│ GitHub Actions Workflow: │
│ - Checkout │
│ - Build images │
│ - Push to ghcr.io │
└────────┬─────────────────────┘
│
▼
┌──────────────────────────────┐
│ ghcr.io Registry │
│ - auth_api:latest │
│ - employee_api:latest │
│ - transit_api:latest │
└────────┬─────────────────────┘
│
▼
┌──────────────────────────────┐
│ Portainer (Remote Server) │
│ docker-compose pull & up │
└──────────────────────────────┘
```
---
## 📝 Image Tags Automatisering
GitHub Actions genereert automatisch tags:
| Event | Tag |
|-------|-----|
| Push naar main | `latest`, `main-{SHA}` |
| Push naar develop | `develop-{SHA}` |
| Release/Tag | `v1.0.0`, `1.0`, `1` |
**Voorbeeld:**
```
ghcr.io/sven/transit_project/auth_api:latest
ghcr.io/sven/transit_project/auth_api:main-a1b2c3d
ghcr.io/sven/transit_project/auth_api:v1.0.0
```
---
## ⚙️ Configuratie
### docker-compose.portainer.yml aanpassen
1. **Vervang dit:**
```
ghcr.io/YOUR_USERNAME/YOUR_REPO/auth_api:latest
```
2. **Met jouw gegevens:**
```
ghcr.io/sven/transit_project/auth_api:latest
```
3. **Voor private image, pull policy toevoegen:**
```yaml
auth_api:
image: ghcr.io/sven/transit_project/auth_api:latest
pull_policy: always
```
### Environment variabelen
**Maak .env file op Portainer server:**
```bash
POSTGRES_DB=transit_db
POSTGRES_USER=transit_user
POSTGRES_PASSWORD=super_secure_password
JWT_SECRET=your_secret_key_here
```
**Dan starten:**
```bash
docker-compose --env-file .env -f docker-compose.portainer.yml up -d
```
---
## 🔐 Veiligheid
### GitHub Token limieten
- Standaard `GITHUB_TOKEN` werkt goed
- Verloopt na 60 minuten (genoeg voor build)
### Private Registry
Als je Portainer server geen internet heeft:
- Use **Portainer Registry Mirror**
- Of **Private Registry** (Harbor, Nexus)
### Credentials in Portainer
**Nooit credentials in docker-compose zetten!**
✅ Juist: Environment file
```bash
docker-compose --env-file .env up -d
```
❌ Fout:
```yaml
environment:
POSTGRES_PASSWORD: password123 # DON'T DO THIS
```
---
## 🐛 Troubleshooting
### "Image not found"
```bash
# Check of image in registry staat
docker pull ghcr.io/USERNAME/REPO/auth_api:latest
# Kijk in GitHub Actions logs
# Check je IMAGE naam in workflow
```
### "Unauthorized: authentication required"
```bash
# Login opnieuw
docker login ghcr.io
# Check token permissies (scope: write:packages)
```
### "Connection refused" naar database
```bash
# Check Docker network
docker network ls
docker network inspect YOUR_NETWORK
# Check database running
docker ps | grep postgres
```
### Logs bekijken
```bash
# Via docker
docker logs auth_api
# Via Portainer: Containers → Select → Logs
```
---
## 📚 Nuttige Commando's
```bash
# Check beschikbare images/tags
curl -s -H "Authorization: Bearer TOKEN" \
https://ghcr.io/v2/USERNAME/REPO/auth_api/tags/list | jq
# Manual image build (als je workflow debug)
docker build -t ghcr.io/USERNAME/REPO/auth_api:test ./auth_api
docker push ghcr.io/USERNAME/REPO/auth_api:test
# Pull latest images
docker-compose -f docker-compose.portainer.yml pull
# Recreate containers (na pull)
docker-compose -f docker-compose.portainer.yml up -d --force-recreate
```
---
## 🎯 Samenvatting
| Stap | Wat | Waar |
|------|-----|------|
| 1 | Code pushen | GitHub |
| 2 | Images bouwen | GitHub Actions |
| 3 | Images pushen | ghcr.io registry |
| 4 | Images pullen | Portainer server |
| 5 | Containers starten | Docker Compose |
**Voordelen:**
- ✅ Volledig geautomatiseerd
- ✅ Versiebeheer via git tags
- ✅ Makkelijk rollback
- ✅ Geen lokale builds nodig
- ✅ CI/CD best practices
-240
View File
@@ -1,240 +0,0 @@
# Docker Setup voor Transit Project APIs
Dit project bevat Docker-configuratie voor alle APIs, Admin Control Center en PostgreSQL database.
## Bestanden
- **auth_api/Dockerfile** - Image voor Authentication API (port 5000)
- **employee_api/Dockerfile** - Image voor Employee API (port 4000)
- **transit_api/Dockerfile** - Image voor Transit API (port 5001)
- **admin_ui/Dockerfile** - Image voor Admin Control Center (port 5174)
- **docker-compose.yml** - Orchestratie van alle services
- **.dockerignore** - Bestanden die niet in Docker images opgenomen worden
- **.env.example** - Template voor environment variabelen
- **DEPLOYMENT.md** - Gedetailleerde deployment guide
- **scripts/deploy.sh** - Deployment helper script
- **scripts/build-and-push.sh** - Build en push script voor registry
## Vereisten
- Docker (>=20.10)
- Docker Compose (>=2.0)
## Installatie
### 1. Environment variabelen configureren
```bash
cp .env.example .env
# Bewerk .env met je eigen waarden
```
### 2. Services starten
```bash
# Start alle services (APIs + Admin UI)
docker-compose up -d
# Of build en start tegelijk (als je code hebt aangepast)
docker-compose up -d --build
# Quick start met script
./scripts/deploy.sh start
```
### 3. Logs bekijken
```bash
# Alle logs
docker-compose logs -f
# Logs van specifieke service
docker-compose logs -f auth_api
docker-compose logs -f transit_api
docker-compose logs -f admin_ui
# Via script
./scripts/deploy.sh logs admin_ui
```
## Beschikbare Services
| Service | Port | Beschrijving |
|---------|------|--------------|
| admin_ui | 5174 | Admin Control Center (React + Nginx) |
| auth_api | 5000 | Authentication endpoints |
| employee_api | 4000 | Employee management endpoints |
| transit_api | 5001 | Transit management endpoints |
## Commando's
### Services beheren (Quick way)
```bash
# Via helper script
./scripts/deploy.sh start # Start alle services
./scripts/deploy.sh stop # Stop alle services
./scripts/deploy.sh restart # Herstart alle services
./scripts/deploy.sh status # Show status
./scripts/deploy.sh logs # Bekijk logs admin_ui
./scripts/deploy.sh logs transit_api # Bekijk logs specifieke service
```
### Services beheren (Docker compose)
```bash
# Start services
docker-compose up -d
# Stop services
docker-compose down
# Herstart services
docker-compose restart
# Verwijder containers en volumes
docker-compose down -v
```
### Individuele services
```bash
# Herstart specifieke service
docker-compose restart auth_api
docker-compose restart admin_ui
# Build specifieke image
docker-compose build auth_api
# Logs van specifieke service
docker-compose logs -f transit_api
```
### Admin UI
```bash
# Bekijk Admin UI
http://localhost:5174
# Logs
docker-compose logs -f admin_ui
# Shell in container
docker-compose exec admin_ui sh
```
## Troubleshooting
### Port al in gebruik
```bash
# Check welke container de port gebruikt
docker ps
# Verander port in docker-compose.yml (e.g., "5000:5000" naar "5010:5000")
```
### Database connectie fout
```bash
# Check database status
docker-compose logs db
# Herstart database
docker-compose restart db
```
### API start probleem
```bash
# Check logs
docker-compose logs auth_api
# Controleer environment variabelen in .env
# Controleer database connection string
```
### Admin UI niet bereikbaar
```bash
# Check Admin UI container
docker-compose logs admin_ui
# Zorg ervoor dat poort 5174 niet in gebruik is
lsof -i :5174
```
## Admin Control Center
De Admin UI is een modern React + Vite applicatie die alle admin functies biedt.
### Features
- **Authenticatie**: JWT-based login met dev token helper
- **Stops Management**: Create en beheer transit stops met geo-coördinaten
- **Bus Lines**: Define bus routes met start/end stops en reistijd
- **Fleet Management**: Monitor fleet vehicles
- **Stop Linking**: Assign stops aan bus lines met volgorde en ETA
### Toegang
```
URL: http://localhost:5174
```
### Deployment Configuratie
Environment variabelen in docker-compose.yml:
- `VITE_AUTH_API`: URL naar auth_api (standaard: http://auth_api:5000)
- `VITE_TRANSIT_API`: URL naar transit_api (standaard: http://transit_api:5001)
Voor externe toegang, update de URLs naar je deployment domain.
### Build en Deployment
```bash
# Build Admin UI image
docker-compose build admin_ui
# Push naar registry
./scripts/build-and-push.sh ghcr.io/your-org/xtransit latest
# Deploy met updated image
docker-compose up -d
```
## Development
### Wijzigingen doorgevoerd in code
```bash
# Rebuild container
docker-compose build auth_api
# Herstart service
docker-compose restart auth_api
```
### Code reloading (hot reload)
Voor hot reload kunt u volumes toevoegen aan docker-compose.yml:
```yaml
volumes:
- ./auth_api:/app
```
Dit kan performance beïnvloeden. Alleen gebruiken tijdens development.
## Productie Tips
1. **Veiligheid**: Wijzig alle wachtwoorden en JWT_SECRET in .env
2. **Database**: Gebruik een managed database service in productie
3. **Logging**: Configureer centralized logging (ELK stack, Splunk, etc.)
4. **Backups**: Stel automatische backups in voor de database
5. **Health Checks**: Monitoreer de health endpoints van de APIs
6. **SSL/TLS**: Gebruik reverse proxy (nginx) voor HTTPS
## Netwerking
De services kunnen elkaar bereiken via:
- auth_api: `http://auth_api:5000`
- employee_api: `http://employee_api:4000`
- transit_api: `http://transit_api:5001`
- database: `db:5432`
-157
View File
@@ -1,157 +0,0 @@
# Quick Start Guide - XTransit Admin Control Center
## 🚀 5-Minute Setup
### Step 1: Initial Setup
```bash
# From the project root
./scripts/init.sh
```
This will:
- Check Docker and Docker Compose installation
- Create `.env` file from template
- Build all Docker images
- Prompt you to configure database credentials
### Step 2: Configure Database Connection
Edit `.env` with your database details:
```env
POSTGRES_HOST=your-db-host.com
POSTGRES_USER_AUTH=transitAUTH
POSTGRES_PASSWORD_AUTH=your_password
POSTGRES_USER_TRANSIT=transitAPI
POSTGRES_PASSWORD_TRANSIT=your_password
JWT_SECRET=your-secure-secret
# ADMIN_USER_CODES should be set in auth_api/.env (comma-separated admin user codes)
```
### Step 3: Start Services
```bash
./scripts/deploy.sh start
```
### Step 4: Access Admin UI
```
http://localhost:5174
```
Log in with your real user code and password.
## 📱 Admin UI Features
### Stops Management
- Create new transit stops
- Set geolocation (latitude, longitude)
- View all stops
- Link stops to bus lines
### Bus Lines
- Create new bus routes
- Define start and end stops
- Set estimated travel time
- Assign stops to lines with order
### Fleet
- View fleet vehicles (read-only)
- See vehicle details
## 🐳 Docker Services
| Port | Service | URL |
|------|---------|-----|
| 5174 | Admin UI | http://localhost:5174 |
| 5000 | Auth API | http://localhost:5000 |
| 5001 | Transit API | http://localhost:5001 |
| 4000 | Employee API | http://localhost:4000 |
## 🔧 Common Commands
```bash
# View service status
./scripts/deploy.sh status
# Restart all services
./scripts/deploy.sh restart
# View logs
./scripts/deploy.sh logs admin_ui
./scripts/deploy.sh logs transit_api
# Stop services
./scripts/deploy.sh stop
# Clean up containers and volumes
./scripts/deploy.sh clean
```
### Authentication
Use the real login flow with user code and password.
### Production
- Keep `DEV_BYPASS=0` and `DEV_MOCK=0`
- Configure proper admin user codes in `ADMIN_USER_CODES`
- Ensure JWTs include the `userCode` claim
## 📝 Configuration Files
- **docker-compose.yml** - All service definitions
- **.env** - Database and API credentials
- **DEPLOYMENT.md** - Comprehensive deployment guide
- **DOCKER_README.md** - Docker-specific documentation
## ⚠️ Troubleshooting
### "Address already in use"
```bash
# Find and stop the process using the port
lsof -i :5174 # Find process on port 5174
kill -9 <PID> # Kill the process
```
### "Cannot connect to database"
```bash
# Check database credentials in .env
# Verify database is accessible from your machine
# Ensure database user exists and has proper permissions
```
### "Admin UI not loading"
```bash
# Check container is running
docker-compose ps admin_ui
# View logs
docker-compose logs -f admin_ui
# Restart service
docker-compose restart admin_ui
```
### "Permission denied" errors
The transit API user needs proper database permissions:
```sql
-- Grant permissions to transitAPI user
GRANT CONNECT ON DATABASE "XTransit" TO transitAPI;
GRANT USAGE ON SCHEMA public TO transitAPI;
GRANT SELECT, INSERT, UPDATE ON all tables IN SCHEMA public TO transitAPI;
```
## 📚 For More Information
- **DEPLOYMENT.md** - Full deployment guide for production
- **DOCKER_README.md** - Docker and container information
- **admin_ui/README.md** - Admin UI specific details
## 🎯 Next Steps
1. ✅ Run initial setup script
2. ✅ Configure database credentials
3. ✅ Start services
4. ✅ Access Admin UI at http://localhost:5174
5. ✅ Create test stops and bus lines
6. ✅ Test the admin interface
For production deployment, see **DEPLOYMENT.md** for security and configuration best practices.
+130 -115
View File
@@ -1,23 +1,37 @@
# Transit Project # Transit Project
Dit repository bevat een suite van Node.js microservices, een admin webapp en een legacy desktop client die momenteel als deprecated wordt bewaard. This repository contains a suite of Node.js microservices, a modern React administration console, an operational monitoring dashboard, and a legacy client retained for compatibility reference.
- `auth_api` — Authenticatie en token-uitgifte (standaard port 5000) - `auth_api` — Authentication and token issuance (default port 5000)
- `employee_api` — Employee onboarding / management (standaard port 4000) - `employee_api` — Employee onboarding and management (default port 4000)
- `transit_api` — Transit/schedule endpoints (standaard port 5001) - `transit_api` — Routes, schedules, fleet, GPS, and transit administration (default port 5001)
- `error_log_api` — Centrale foutlogging en viewer (standaard port 5003) - `error_log_api` — Centralized error logging and viewer (default port 5003)
- `admin_ui` — React + Vite beheerinterface
- `deprecated/` — legacy Flutter bus terminal, retained for reference while moving the client to a Windows XP-era build path
Er is ook een GitHub Actions workflow aanwezig die Docker images bouwt en pusht naar GitHub Container Registry (GHCR). The error API is available at `https://error.xtransit.testinstance.nl`.
Belangrijk: deze README bevat geen wachtwoorden of andere gevoelige gegevens. Sla secrets altijd buiten versiebeheer op (environment variables, secret managers, Portainer secrets, etc.). - `admin_ui` — React + Vite administration interface
The admin panel is available at `https://admin.xtransit.testinstance.nl`.
- `monitoring_ui` — React + Vite service monitoring dashboard
The monitoring dashboard is available at host port 5175 (`https://monitor.xtransit.testinstance.nl` or local port 5173).
- `windows_xp_driver_ui/` — Native Win32 driver client targeting Windows XP-era systems
- `deprecated/` — Legacy Flutter bus terminal, retained for reference while moving the client to a Windows XP-era build path
The repository also includes CI workflows that build Docker images and push them to GitHub Container Registry (GHCR).
See [monitoring_ui/docs/DOMAINS_README.md](monitoring_ui/docs/DOMAINS_README.md) for the central list of all public domains and their functions.
The consolidated documentation hub is available in [monitoring_ui/docs/README.md](monitoring_ui/docs/README.md).
Important: this README does not contain any passwords or other sensitive information. Always store secrets outside of version control (environment variables, secret managers, Portainer secrets, etc.).
--- ---
## Security & Private Files ## Security & Private Files
**⚠️ Important:** This repository excludes private documentation and setup instructions from Git and Docker builds. See [SECURITY_PRIVATE_FILES.md](SECURITY_PRIVATE_FILES.md) for details on: **⚠️ Important:** This repository excludes private documentation and setup instructions from Git and Docker builds. See [monitoring_ui/docs/SECURITY_PRIVATE_FILES.md](monitoring_ui/docs/SECURITY_PRIVATE_FILES.md) for details on:
- What files are kept private and why - What files are kept private and why
- How `.gitignore` and `.dockerignore` protect sensitive information - How `.gitignore` and `.dockerignore` protect sensitive information
@@ -34,47 +48,52 @@ Belangrijk: deze README bevat geen wachtwoorden of andere gevoelige gegevens. Sl
--- ---
## Overzicht van inhoud ## Table of Contents / Directory Overview
- `auth_api/` — source voor auth service - `auth_api/` — Source code for the authentication service
- `employee_api/` — source voor employee service - `employee_api/` — Source code for the employee service
- `transit_api/` — source voor transit service - `transit_api/` — Source code for the transit service
- `.github/workflows/docker-build.yml` — CI: bouw en push images naar GHCR - `error_log_api/` — Source code for centralized error collection
- `docker-compose.yml` — compose file die (in deze workspace-versie) images trekt van `ghcr.io/...` en verwacht dat je een externe PostgreSQL hebt draaien - `admin_ui/` — Source code for React admin panel
- `.env.example` — voorbeeld environment variabelen - `monitoring_ui/` — Source code for React monitoring dashboard and documentation hub
- `DOCKER_README.md` & `CI_CD_PORTAINER_GUIDE.md` — extra docs voor Docker/CI - `windows_xp_driver_ui/` — Native driver interface
- `deprecated/` — Legacy client reference
- `.github/workflows/docker-build.yml` — CI: build and push images to GHCR
- `docker-compose.yml` — Compose file that pulls images from GHCR or builds locally and connects to an external PostgreSQL database
- `.env.example` — Example environment variables
- `monitoring_ui/docs/` — Consolidated documentation hub
--- ---
## Vereisten ## Prerequisites
- Git - Git
- Docker (20.10+) - Docker (20.10+)
- Docker Compose (v2+ of `docker compose`) - Docker Compose (v2+ or `docker compose`)
- (Optioneel) een remote omgeving met Portainer - (Optional) A remote environment running Portainer
- (Optioneel) Een GitHub account met rechten om images naar GHCR te publiceren - (Optional) A GitHub account with permissions to publish images to GHCR
--- ---
## Veiligheid — belangrijke reminders ## Security — Important Reminders
- Plaats geen secrets in git. Gebruik een `.env`-bestand lokaal (dat je in `.gitignore` houdt) of een secret manager. - Never commit secrets into git. Use a local `.env` file (kept in `.gitignore`) or a secret manager.
- JWT secrets, database wachtwoorden en andere credentials moeten via environment variables of Portainer secrets geleverd worden. - JWT secrets, database passwords, and other credentials must be supplied via environment variables or Portainer secrets.
--- ---
## Environment variabelen (voorbeeld) ## Environment Variables (Example)
Je `docker-compose.yml` verwacht environment variabelen die de database-verbinding en JWT secret definiëren. Your `docker-compose.yml` expects environment variables defining the database connection and JWT secret.
Maak een local `.env` bestand in de projectroot (NIET commiten). Gebruik deze variabelen als leidraad: Create a local `.env` file in the project root (DO NOT commit). Use these variables as a guide:
``` ```env
# Database (externe Postgres) # Database (external Postgres)
POSTGRES_HOST=your.db.host.or.ip POSTGRES_HOST=your.db.host.or.ip
POSTGRES_PORT=5432 POSTGRES_PORT=5432
POSTGRES_DB=transit_db POSTGRES_DB=transit_db
# Per-service DB-credentials (optioneel: kun ook één DB-user gebruiken per DB) # Per-service DB credentials (optional: you can also use a single DB user per DB)
POSTGRES_USER_AUTH=auth_user POSTGRES_USER_AUTH=auth_user
POSTGRES_PASSWORD_AUTH=strong_password_auth POSTGRES_PASSWORD_AUTH=strong_password_auth
POSTGRES_USER_EMPLOYEE=employee_user POSTGRES_USER_EMPLOYEE=employee_user
@@ -84,29 +103,29 @@ POSTGRES_PASSWORD_TRANSIT=strong_password_transit
POSTGRES_USER_ERROR_LOG=error_logger POSTGRES_USER_ERROR_LOG=error_logger
POSTGRES_PASSWORD_ERROR_LOG=strong_password_error_log POSTGRES_PASSWORD_ERROR_LOG=strong_password_error_log
# JWT secret voor services die JWT gebruiken # JWT secret for services using JWT
JWT_SECRET=super_secret_jwt_key_here JWT_SECRET=super_secret_jwt_key_here
# Optioneel: centraal error log endpoint voor clients/services # Optional: central error log endpoint for 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. Note: The exact variables are read by `docker-compose.yml`. Adjust names and values according to your environment.
--- ---
## Lokale ontwikkeling (zonder Docker) ## Local Development (Without Docker)
1. Zorg dat je een PostgreSQL instance hebt draaien (lokal of remote). 1. Ensure you have a running PostgreSQL instance (local or remote).
2. Kopieer per API een `.env` of export de benodigde vars. 2. Copy a `.env` file for each API or export the required environment variables.
Voorbeeld: run de `auth_api` lokaal Example: running `auth_api` locally:
```bash ```bash
cd auth_api cd auth_api
# installeer dependencies # Install dependencies
npm install npm install
# export env vars (voorbeeld): # Export env vars (example):
export DB_HOST=your.db.host export DB_HOST=your.db.host
export DB_PORT=5432 export DB_PORT=5432
export DB_NAME=transit_db export DB_NAME=transit_db
@@ -117,60 +136,60 @@ export JWT_SECRET=...
npm start npm start
``` ```
Herhaal voor `employee_api` en `transit_api` met de juiste env keys/poorten. Repeat for `employee_api`, `transit_api`, and `error_log_api` with the appropriate env keys and ports.
Tip: tijdens development kun je één Postgres instance gebruiken en schema/rollen configureren. Tip: During development, you can use a single Postgres instance and configure schemas and roles.
--- ---
## Docker / Portainer — deployment (met externe Postgres) ## Docker / Portainer — Deployment (With External Postgres)
Deze repository bevat `docker-compose.yml` die images vanuit GHCR trekt. De compose is aangepast om een externe Postgres te gebruiken. This repository includes a `docker-compose.yml` file that pulls images from GHCR. The compose setup is configured to use an external Postgres instance.
Voordat je start: Before starting:
- Zorg dat je `.env` met database credentials en `JWT_SECRET` klaar staat op de server. - Ensure your `.env` file with database credentials and `JWT_SECRET` is prepared on the server.
- Zorg dat de host waar je compose draait netwerk-connectiviteit heeft naar de Postgres host. - Ensure the host running compose has network connectivity to the Postgres host.
Stappen om te deployen (server/Portainer host): Deployment steps (server / Portainer host):
```bash ```bash
# 1. Zet .env op server (NIET in git) # 1. Place .env on the server (NOT in git)
scp .env user@server:/path/to/project/.env scp .env user@server:/path/to/project/.env
# of maak lokaal op de server # or create it directly on the server
# 2. Login bij GHCR (om private images te kunnen pullen) # 2. Login to GHCR (to pull private images)
# Username = je GitHub-gebruikersnaam # Username = your GitHub username
# Password = PAT met 'write:packages'/'read:packages' scopes of je GITHUB_TOKEN # Password = PAT with 'write:packages'/'read:packages' scopes or your GITHUB_TOKEN
docker login ghcr.io docker login ghcr.io
# 3. Start stack # 3. Start stack
docker-compose --env-file .env up -d docker-compose --env-file .env up -d
# of met compose V2: # or with compose V2:
docker compose --env-file .env up -d docker compose --env-file .env up -d
``` ```
Als je Portainer gebruikt kun je `docker-compose.yml` ook als "Stack" in de Portainer UI plakken en deployment via de UI uitvoeren. Voeg de environment variabelen in Portainer toe of verwijs naar een server-side `.env`. If using Portainer, you can also paste `docker-compose.yml` as a "Stack" in the Portainer UI and deploy via the UI. Add environment variables in Portainer or reference a server-side `.env`.
Opmerking over `depends_on` in `docker-compose.yml`: omdat je een externe DB gebruikt, verwijst de compose soms nog naar een non-existent `db` service. Je kunt die `depends_on` regels veilig verwijderen of negeren, zolang je database beschikbaar is voordat de services de verbinding proberen te maken. Note on `depends_on` in `docker-compose.yml`: because you are using an external DB, compose configs sometimes reference a non-existent `db` service. You can safely remove or ignore those `depends_on` rules, as long as your database is available before the services attempt to connect.
--- ---
## GitHub Actions & Container Registry (CI) ## GitHub Actions & Container Registry (CI)
Het project bevat een workflow in `.github/workflows/docker-build.yml` die bij push naar `main`/`develop` de volgende stappen uitvoert: The project includes a CI workflow in `.github/workflows/docker-build.yml` (and `.gitea/workflows/docker-build.yml`) that executes the following steps on push to `main`/`dev`:
- Checkout - Checkout repository
- Build Docker images per API (met BuildKit/Buildx) - Build Docker images for each API (using BuildKit / Buildx)
- Login naar de Container Registry (GHCR) en push images - Login to the Container Registry (GHCR) and push images
- Genereert tags (`latest`, branch/sha, semver als aanwezig) - Generate tags (`latest`, branch/sha, semver if present)
Om GHCR te gebruiken op je server: To use GHCR on your server:
1. Maak een Personal Access Token (PAT) met `read:packages` (en `write:packages` indien je zelf ook wilt pushen). 1. Create a Personal Access Token (PAT) with `read:packages` (and `write:packages` if you also wish to push).
2. Login op de server: `docker login ghcr.io` (gebruik PAT als wachtwoord). 2. Login on the server: `docker login ghcr.io` (use PAT as password).
Image naam voorbeeld: Example image name:
``` ```
ghcr.io/<github-username>/<repo>/auth_api:latest ghcr.io/<github-username>/<repo>/auth_api:latest
@@ -178,36 +197,36 @@ ghcr.io/<github-username>/<repo>/auth_api:latest
--- ---
## Health checks, endpoints en debugging ## Health Checks, Endpoints, and Debugging
Enkele nuttige endpoints (voorbeeld): Useful endpoints (example):
- `GET /health` — (beschikbaar in `employee_api`) controleert reachability - `GET /health` — (available in `employee_api`, `transit_api`, `error_log_api`) checks reachability
- `POST /login` — (`auth_api`) authenticatie - `POST /login` — (`auth_api`) authentication
- `GET /schedule/daily?date=YYYY-MM-DD` — (`transit_api`) vereist auth - `GET /schedule/daily?date=YYYY-MM-DD` — (`transit_api`) requires authentication
Logs bekijken: Viewing logs:
```bash ```bash
# Logs van service # Logs from a service
docker-compose logs -f auth_api docker-compose logs -f auth_api
# Of via container id # Or via container ID
docker logs -f auth_api docker logs -f auth_api
``` ```
Problemen met DB-verbinding: Troubleshooting DB connection:
- Controleer netwerkconnectiviteit naar `POSTGRES_HOST` - Verify network connectivity to `POSTGRES_HOST`
- Controleer users/permissions in Postgres - Check users and permissions in Postgres
- Controleer dat de juiste schema (zoals `workers`) bestaat als de queries die verwachten - Ensure the expected schema (such as `workers` or `transit`) exists if queries expect it
--- ---
## Veelvoorkomende taken & commando's ## Common Tasks & Commands
```bash ```bash
# Build & run (pull images van GHCR) # Build & run (pull images from GHCR)
docker-compose --env-file .env up -d docker-compose --env-file .env up -d
# Pull latest images # Pull latest images
@@ -225,30 +244,30 @@ docker login ghcr.io
--- ---
## Migraties & Database schema ## Migrations & Database Schema
Database schema en migraties zitten niet in deze repository. Zorg dat je: Database schemas and migrations are maintained separately. Ensure you:
- de juiste schema's (`workers`, `routes`, etc.) aanmaakt - Create the necessary schemas (`workers`, `transit`, `data`, etc.)
- gebruikers en rechten configureert voor de verschillende services - Configure users and privileges for the respective services
### Error logging schema ### Error Logging Schema
Voor de centrale foutlogging maak je schema `data` en tabel `data.errors` aan: For centralized error logging, create schema `data` and table `data.errors`:
```sql ```sql
CREATE SCHEMA IF NOT EXISTS data; CREATE SCHEMA IF NOT EXISTS data;
CREATE TABLE IF NOT EXISTS data.errors ( 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
); );
``` ```
Gebruik een aparte gebruiker met minimale rechten, bijvoorbeeld `error_logger`: Use a dedicated user with minimal privileges, e.g. `error_logger`:
```sql ```sql
CREATE ROLE error_logger LOGIN PASSWORD 'strong_password_error_log'; CREATE ROLE error_logger LOGIN PASSWORD 'strong_password_error_log';
@@ -256,38 +275,39 @@ GRANT USAGE ON SCHEMA data TO error_logger;
GRANT INSERT, SELECT ON data.errors TO error_logger; GRANT INSERT, SELECT ON data.errors TO error_logger;
``` ```
Als je nog geen database hebt, kun je lokaal een Postgres container gebruiken (development): If you do not have a database yet, you can run a local PostgreSQL container for development:
```bash ```bash
docker run --name transit-db -e POSTGRES_DB=transit_db -e POSTGRES_USER=transit_user -e POSTGRES_PASSWORD=pass -p 5432:5432 -d postgres:15-alpine docker run --name transit-db -e POSTGRES_DB=transit_db -e POSTGRES_USER=transit_user -e POSTGRES_PASSWORD=pass -p 5432:5432 -d postgres:15-alpine
``` ```
Maar voor je production/Portainer setup volg je je bestaande beheersbeleid en gebruik je een managed DB of beveiligde host. For production / Portainer setups, follow your infrastructure policy and use a managed database or secured host.
--- ---
## Deployment tips ## Deployment Tips
- Gebruik verschillende database users per service met minimale rechten - Use distinct database users per service with minimal privileges
- Gebruik secrets in Portainer of Docker swarm/kubernetes secrets in productie - Use secrets in Portainer, Docker swarm, or Kubernetes in production
- Gebruik een reverse proxy (nginx, Traefik) voor TLS/HTTPS - Use a reverse proxy (Nginx, Traefik) for TLS/HTTPS
- Stel log aggregatie en monitoring in (Prometheus/Grafana, ELK, etc.) - Set up log aggregation and monitoring (Prometheus/Grafana, ELK, etc.)
--- ---
## Contributie ## Contributing
- Maak feature branches en open pull requests - Create feature branches and open pull requests
- Push naar `develop` of `main` afhankelijk van je branching policy - Push to `dev` or `main` depending on your branching policy
- CI zal automatisch images bouwen en pushen voor `main`/`develop` - CI will automatically build and push images for `main`/`dev`
--- ---
## Waar te vinden ## Where to Find Everything
- API code: `auth_api/`, `employee_api/`, `transit_api/` - API code: `auth_api/`, `employee_api/`, `transit_api/`, `error_log_api/`
- CI workflow: `.github/workflows/docker-build.yml` - Frontend UI: `admin_ui/`, `monitoring_ui/`
- Extra docs: `DOCKER_README.md`, `CI_CD_PORTAINER_GUIDE.md` - Documentation hub: `monitoring_ui/docs/`
- CI workflows: `.github/workflows/docker-build.yml`, `.gitea/workflows/docker-build.yml`
--- ---
@@ -322,7 +342,6 @@ admin_ui/
│ └── styles.css # Responsive UI styling │ └── styles.css # Responsive UI styling
├── docker-entrypoint.sh # Runtime env config ├── docker-entrypoint.sh # Runtime env config
└── Dockerfile # Multi-stage build └── Dockerfile # Multi-stage build
``` ```
### Building the Admin UI ### Building the Admin UI
@@ -331,7 +350,7 @@ admin_ui/
cd admin_ui cd admin_ui
npm install npm install
npm run build # Production build npm run build # Production build
npm run dev # Development (Vite) npm run dev # Development (Vite)
``` ```
### Docker Deployment ### Docker Deployment
@@ -356,7 +375,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:**
@@ -369,7 +388,7 @@ import TimetableViewer from "./components/TimetableViewer";
### Schedule Maker Documentation ### Schedule Maker Documentation
See [admin_ui/SCHEDULE_API.md](admin_ui/SCHEDULE_API.md) for detailed API documentation, usage examples, and UI features. See [monitoring_ui/docs/admin_ui/SCHEDULE_API.md](monitoring_ui/docs/admin_ui/SCHEDULE_API.md) for detailed API documentation, usage examples, and UI features.
--- ---
@@ -404,7 +423,3 @@ ADMIN_USER_CODES=42 DEV_MOCK=1 JWT_SECRET=devsecret npm --prefix ./auth_api star
### Production Mode ### Production Mode
Set up real PostgreSQL database and provide credentials via environment variables. Set up real PostgreSQL database and provide credentials via environment variables.
---
Als je wilt, kan ik deze README nog aanpassen met specifieke voorbeelden van database schema's of exacte environment-variabele namen voor lokaal draaien. Zeg welke details je wil toevoegen (bijv. example SQL voor `workers` schema).
+1
View File
@@ -7,6 +7,7 @@ RUN npm run build
FROM nginx:stable-alpine FROM nginx:stable-alpine
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-entrypoint.sh /docker-entrypoint.sh COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh
EXPOSE 80 EXPOSE 80
+3 -3
View File
@@ -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 -3
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
server {
listen 80;
root /usr/share/nginx/html;
location = /health {
default_type application/json;
add_header Cache-Control "no-store";
return 200 '{"status":"ok","service":"admin_ui"}';
}
location / {
try_files $uri $uri/ /index.html;
}
}
+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 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 };
+45 -31
View File
@@ -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>
) );
} }
+34 -66
View File
@@ -1,93 +1,61 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from "react";
import { transitClient } from '../api' import { transitClient } from "../api";
import TimetableDisplay from './TimetableDisplay' import WeeklyScheduleEditor from "./WeeklyScheduleEditor";
import FixedScheduleEditor from './FixedScheduleEditor'
export default function ScheduleMaker({ token, user }) { export default function ScheduleMaker({ token }) {
const client = transitClient(token) const client = transitClient(token);
const [scheduleTemplates, setScheduleTemplates] = useState([]) const [schedules, setSchedules] = useState([]);
const [busLines, setBusLines] = useState([]) const [busLines, setBusLines] = useState([]);
const [buses, setBuses] = useState([]) const [drivers, setDrivers] = useState([]);
const [drivers, setDrivers] = useState([]) const [message, setMessage] = useState(null);
const [selectedDriverFilter, setSelectedDriverFilter] = useState('')
const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.isAdmin))
const [overviewDate, setOverviewDate] = useState(new Date().toISOString().split('T')[0])
const loadScheduleTemplates = async () => { const loadSchedules = async () => {
try { try {
const res = await client.get('/admin/schedules?templates=1') const res = await client.get("/admin/weekly-schedules");
setScheduleTemplates((res.data.schedules || []).filter(schedule => Array.isArray(schedule.weekdays) && schedule.weekdays.length > 0)) setSchedules(res.data.schedules || []);
} catch (err) { } catch (err) {
setMessage('Error loading fixed schedules: ' + (err.response?.data?.error || err.message)) setMessage("Could not load weekly schedules.");
} }
} };
const loadBusLines = async () => { const loadBusLines = async () => {
try { try {
const res = await client.get('/admin/bus-lines') const res = await client.get("/admin/bus-lines");
setBusLines(res.data.busLines || []) setBusLines(res.data.busLines || []);
} catch (err) { } catch (err) {
setMessage('Error loading bus lines: ' + (err.response?.data?.error || err.message)) setMessage("Could not load bus lines.");
} }
} };
const loadBuses = async () => {
try {
const res = await client.get('/admin/fleet/buses')
setBuses(res.data.buses || [])
} catch (err) {
setMessage('Error loading buses: ' + (err.response?.data?.error || err.message))
}
}
const loadDrivers = async () => { const loadDrivers = async () => {
try { try {
const res = await client.get('/admin/drivers') const res = await client.get("/admin/shift-drivers");
setDrivers(res.data.drivers || []) setDrivers(res.data.drivers || []);
} catch (err) { } catch (err) {
setMessage('Error loading drivers: ' + (err.response?.data?.error || err.message)) setMessage("Could not load drivers.");
} }
} };
useEffect(() => { useEffect(() => {
loadScheduleTemplates() loadSchedules();
loadBusLines() loadBusLines();
loadBuses() loadDrivers();
loadDrivers() }, []);
}, [])
const handleScheduleChange = async () => { const handleScheduleChange = async () => {
await loadScheduleTemplates() await loadSchedules();
} };
return ( return (
<div> <div>
{/* Timetable widget - compact daily view */} <WeeklyScheduleEditor
<TimetableDisplay
client={client} client={client}
overviewDate={overviewDate} busLines={busLines}
setOverviewDate={setOverviewDate}
selectedDriverFilter={selectedDriverFilter}
setSelectedDriverFilter={setSelectedDriverFilter}
drivers={drivers} drivers={drivers}
schedules={schedules}
onChange={handleScheduleChange}
/> />
{message && <div className="message">{message}</div>}
{/* Fixed service editor - separate widget */}
{isAdmin && (
<FixedScheduleEditor
client={client}
token={token}
user={user}
scheduleTemplates={scheduleTemplates}
busLines={busLines}
buses={buses}
drivers={drivers}
message={message}
setMessage={setMessage}
onScheduleChange={handleScheduleChange}
/>
)}
</div> </div>
) );
} }
+187 -84
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' }) { 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;
} }
@@ -0,0 +1,287 @@
import React, { useMemo, useState } from "react";
const DAY_GROUPS = [
{ value: "weekdays", label: "Weekdays", days: [1, 2, 3, 4, 5] },
{ value: "saturday", label: "Saturday", days: [6] },
{ value: "sunday", label: "Sunday", days: [0] },
];
const dayLabel = (day) =>
[
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
][day];
export default function WeeklyScheduleEditor({
client,
busLines,
drivers,
schedules,
onChange,
}) {
const [lineId, setLineId] = useState("");
const [category, setCategory] = useState("weekdays");
const [startTimes, setStartTimes] = useState(["08:00"]);
const [newStartTime, setNewStartTime] = useState("");
const [driverId, setDriverId] = useState("");
const [message, setMessage] = useState(null);
const [saving, setSaving] = useState(false);
const groupedSchedules = useMemo(() => {
return schedules.reduce((groups, schedule) => {
const key =
schedule.dayOfWeek >= 1 && schedule.dayOfWeek <= 5
? "weekdays"
: schedule.dayOfWeek === 6
? "saturday"
: "sunday";
groups[key] = [...(groups[key] || []), schedule];
return groups;
}, {});
}, [schedules]);
const createSchedules = async (event) => {
event.preventDefault();
setMessage(null);
if (!lineId || startTimes.length === 0) {
setMessage("Choose a line and add at least one departure time.");
return;
}
const group = DAY_GROUPS.find((item) => item.value === category);
setSaving(true);
try {
const response = await client.post("/admin/weekly-schedules/bulk", {
busLineId: Number(lineId),
dayOfWeeks: group.days,
startTimes,
driverId: driverId ? Number(driverId) : null,
});
setMessage(
`${response.data.createdCount} departures created for ${group.label}.`,
);
setStartTimes(["08:00"]);
setNewStartTime("");
onChange();
} catch (error) {
setMessage(error.response?.data?.error || "Could not save the schedule.");
} finally {
setSaving(false);
}
};
const addStartTime = () => {
if (!newStartTime || startTimes.includes(newStartTime)) return;
setStartTimes([...startTimes, newStartTime].sort());
setNewStartTime("");
};
const removeStartTime = (time) => {
setStartTimes(startTimes.filter((item) => item !== time));
};
const assignDriver = async (scheduleId, value) => {
try {
await client.put(`/admin/weekly-schedules/${scheduleId}/shift`, {
driverId: value ? Number(value) : null,
});
setMessage("Driver assignment updated.");
onChange();
} catch (error) {
setMessage(
error.response?.data?.error ||
"Could not update the driver assignment.",
);
}
};
const removeSchedule = async (scheduleId) => {
if (!window.confirm("Delete this weekly schedule?")) return;
try {
await client.delete(`/admin/weekly-schedules/${scheduleId}`);
setMessage("Weekly schedule deleted.");
onChange();
} catch (error) {
setMessage(
error.response?.data?.error || "Could not delete the schedule.",
);
}
};
return (
<section className="card">
<div className="section-header">
<div>
<h3>Weekly schedules</h3>
<p>
Add multiple daily departures for weekdays, Saturday, or Sunday,
then assign a driver to the generated templates.
</p>
</div>
</div>
<form onSubmit={createSchedules} className="stack-form">
<div className="form-row">
<select
value={lineId}
onChange={(event) => setLineId(event.target.value)}
>
<option value="">Choose bus line</option>
{busLines.map((line) => (
<option key={line.id} value={line.id}>
Line {line.lineNumber} ({line.startStop} to {line.endStop})
</option>
))}
</select>
<select
value={category}
onChange={(event) => setCategory(event.target.value)}
>
{DAY_GROUPS.map((group) => (
<option key={group.value} value={group.value}>
{group.label}
</option>
))}
</select>
</div>
<div className="form-row" style={{ alignItems: "center" }}>
<input
type="time"
value={newStartTime}
onChange={(event) => setNewStartTime(event.target.value)}
aria-label="New departure time"
/>
<button type="button" className="secondary" onClick={addStartTime}>
Add departure
</button>
<div
style={{
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
flex: 1,
}}
>
{startTimes.map((time) => (
<span
key={time}
style={{
display: "inline-flex",
gap: "0.4rem",
alignItems: "center",
padding: "0.4rem 0.6rem",
border: "1px solid #c7dcff",
borderRadius: "999px",
background: "#eaf2ff",
}}
>
{time}
<button
type="button"
onClick={() => removeStartTime(time)}
aria-label={`Remove departure ${time}`}
style={{
border: "none",
background: "transparent",
cursor: "pointer",
}}
>
x
</button>
</span>
))}
</div>
<select
value={driverId}
onChange={(event) => setDriverId(event.target.value)}
>
<option value="">Unassigned</option>
{drivers.map((driver) => (
<option key={driver.id} value={driver.id}>
{driver.name}
</option>
))}
</select>
<button type="submit" disabled={saving}>
{saving ? "Saving..." : `Create ${startTimes.length} departures`}
</button>
</div>
</form>
{message && <div className="message">{message}</div>}
<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) => (
<option key={driver.id} value={driver.id}>
{driver.name}
</option>
))}
</select>
<button
type="button"
className="danger"
onClick={() => removeSchedule(schedule.id)}
style={{ width: "100%", marginTop: "0.5rem" }}
>
Delete
</button>
</article>
))}
</div>
)}
</section>
))}
</div>
</section>
);
}
+62 -39
View File
@@ -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) });
} }
}); });
@@ -110,4 +129,8 @@ app.get('/me', async (req, res) => {
const PORT = process.env.PORT || 5000; const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Auth API listening on ${PORT}`)); app.listen(PORT, () => console.log(`Auth API listening on ${PORT}`));
app.get("/health", (_req, res) => {
res.json({ status: "ok", service: "auth_api" });
});
module.exports = app; module.exports = app;
+24 -5
View File
@@ -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,9 +79,28 @@ 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
- error_log_api - error_log_api
restart: unless-stopped restart: unless-stopped
monitoring_ui:
image: ${REGISTRY_HOST:-gitea.testinstance.nl}/${IMAGE_NAMESPACE:-sven/the-transit-project}/monitoring_ui:${IMAGE_TAG:-latest}
container_name: monitoring_ui
ports:
- "5175:80"
environment:
AUTH_API: ${AUTH_API:-https://auth.xtransit.testinstance.nl}
EMPLOYEE_API: ${EMPLOYEE_API:-https://employees.xtransit.testinstance.nl}
TRANSIT_API: ${TRANSIT_API:-https://transit.xtransit.testinstance.nl}
ERROR_LOG_API: ${ERROR_LOG_API:-https://error.xtransit.testinstance.nl}
ADMIN_UI: ${ADMIN_UI:-https://admin.xtransit.testinstance.nl}
depends_on:
- auth_api
- employee_api
- transit_api
- error_log_api
- admin_ui
restart: unless-stopped
+84 -58
View File
@@ -1,79 +1,105 @@
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'; app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method === "OPTIONS") return res.sendStatus(204);
next();
});
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) => {
try { try {
return res.status(200).json({endpoint: 'reachable'}) await db.query("SELECT 1");
} return res.status(200).json({ endpoint: "reachable", database: "ok" });
catch (err) { } catch (err) {
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(err) }); console.error(err);
console.error(err); res.status(503).json({ endpoint: "unreachable", database: "down" });
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}`));
+57 -39
View File
@@ -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,43 @@ 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' }); try {
await db.query("SELECT 1");
res.json({ status: "ok", service: "error_log_api", database: "ok" });
} catch (err) {
console.error("GET /health error:", err);
res.status(503).json({ status: "unavailable", 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 +114,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 +140,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;
-40
View File
@@ -1,40 +0,0 @@
# Session Handoff
**Goal:** Make schedules work correctly across recurring and one-time events, with accurate per-day status/progress in the API and calendar views in the admin UI.
**Current Status:**
- `transit_api/index.js` now materializes recurring schedules into concrete occurrences for `/schedules/overview`.
- `transit.schedule_stop_progress` is now occurrence-aware via `scheduled_passed_date`; the API backfills old rows and writes/reads stop progress per date.
- The API check passed with `node --check transit_api/index.js`.
- The admin UI still needs to be wired to use the range-aware overview endpoint in its calendar data source.
**Important Context:**
- The schedule template lives in the database as a row with `weekdays`; recurring events are not stored as separate rows.
- The backend should be the source of truth for date-specific materialization and live progress/status.
- Key files: `transit_api/index.js`, `admin_ui/src/components/ScheduleMaker.jsx`, `transit_api/README.md`.
- The admin UI build already succeeded earlier with `npm --prefix admin_ui run build`.
**Decisions Already Made:**
- Recurring schedules are materialized on read instead of creating one DB row per occurrence. This keeps the DB simple and makes the calendar/date-range API deterministic.
- Progress must be keyed by occurrence date, not only by schedule id and stop order, otherwise a recurring schedule reuses yesterday’s stop state.
- `/schedules/overview` should support both single-date and date-range requests so month/week views can render all relevant occurrences with one call.
**What to Avoid:**
- Do not treat recurring schedules as a single mutable live schedule row for progress/status.
- Do not reuse the old `schedule_id + stop_order` uniqueness for stop progress.
- Avoid reworking unrelated API routes or admin screens unless they block the calendar flow.
**Open Questions or Blockers:**
- The admin UI calendar source still needs to be confirmed and wired to call `/schedules/overview?fromDate=...&toDate=...`.
- If the admin calendar view expects a different event shape, map the overview response there rather than changing the backend again.
**Next Best Step:**
1. Inspect the calendar/timetable code path in `admin_ui/src/components/ScheduleMaker.jsx` and any adjacent component that renders the calendar range.
2. Wire that view to request `/schedules/overview` with `fromDate` and `toDate`.
3. Map returned schedules into calendar events and surface the current status/progress fields.
4. Run a focused admin UI build to confirm the change compiles.
**How to Respond:**
- Start by summarizing the current state in one short paragraph.
- Continue from the existing implementation instead of re-discovering the schedule model.
- If anything critical is missing, ask only the minimum question needed to proceed.
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
npm-debug.log
.env
.env.*
!.env.example
+15
View File
@@ -0,0 +1,15 @@
# Dependencies and build output
node_modules/
dist/
# Local environment configuration
.env.local
.env.*.local
# Logs
*.log
npm-debug.log*
# Local editor files
.DS_Store
.vscode/
+15
View File
@@ -0,0 +1,15 @@
FROM node:18-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build
FROM nginx:stable-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 80
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
+19
View File
@@ -0,0 +1,19 @@
# Transit Control Room
The complete documentation for this dashboard is maintained centrally in [docs/MONITORING_UI.md](docs/MONITORING_UI.md).
That guide covers:
- monitored services and health semantics;
- local development and production configuration;
- CORS and JWT requirements;
- the error-log viewer and controlled request lab;
- deployment options, troubleshooting, and extension rules;
- the validation checklist for dashboard changes.
Run locally with:
```bash
npm install
npm run dev
```
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -e
cat > /usr/share/nginx/html/env-config.js <<EOF
window.__ENV__ = {
AUTH_API: "${AUTH_API:-https://auth.xtransit.testinstance.nl}",
EMPLOYEE_API: "${EMPLOYEE_API:-https://employees.xtransit.testinstance.nl}",
TRANSIT_API: "${TRANSIT_API:-https://transit.xtransit.testinstance.nl}",
ERROR_LOG_API: "${ERROR_LOG_API:-https://error.xtransit.testinstance.nl}",
ADMIN_UI: "${ADMIN_UI:-https://admin.xtransit.testinstance.nl}"
}
EOF
exec "$@"
+245
View File
@@ -0,0 +1,245 @@
# Docker Setup for Transit Project APIs
This project contains Docker configuration for all APIs, the Admin Control Center, the Monitoring UI, and external/local PostgreSQL databases.
## Files
- **auth_api/Dockerfile** - Image for Authentication API (port 5000)
- **employee_api/Dockerfile** - Image for Employee API (port 4000)
- **transit_api/Dockerfile** - Image for Transit API (port 5001)
- **admin_ui/Dockerfile** - Image for Admin Control Center (port 5174)
- **monitoring_ui/Dockerfile** - Image for Monitoring UI Dashboard (port 5175)
- **docker-compose.yml** - Orchestration for all services
- **.dockerignore** - Files excluded from Docker images
- **.env.example** - Template for environment variables
- **DEPLOYMENT.md** - Detailed deployment guide
- **scripts/deploy.sh** - Deployment helper script
- **scripts/build-and-push.sh** - Build and push script for container registry
## Prerequisites
- Docker (>=20.10)
- Docker Compose (>=2.0)
## Installation & Setup
### 1. Configure Environment Variables
```bash
cp .env.example .env
# Edit .env with your specific configuration values
```
### 2. Start Services
```bash
# Start all services (APIs + Admin UI + Monitoring UI)
docker-compose up -d
# Or build and start simultaneously (after code modifications)
docker-compose up -d --build
# Quick start using helper script
./scripts/deploy.sh start
```
### 3. View Logs
```bash
# All service logs
docker-compose logs -f
# Logs for a specific service
docker-compose logs -f auth_api
docker-compose logs -f transit_api
docker-compose logs -f admin_ui
# Via helper script
./scripts/deploy.sh logs admin_ui
```
## Available Services
| Service | Port | Description |
|---------|------|-------------|
| admin_ui | 5174 | Admin Control Center (React + Nginx) |
| monitoring_ui | 5175 | Monitoring Dashboard & Docs Hub (React + Nginx) |
| auth_api | 5000 | Authentication endpoints |
| employee_api | 4000 | Employee management endpoints |
| transit_api | 5001 | Transit management endpoints |
| error_log_api | 5003 | Centralized error logging API |
## Commands
### Manage Services (Quick Way)
```bash
# Using helper script
./scripts/deploy.sh start # Start all services
./scripts/deploy.sh stop # Stop all services
./scripts/deploy.sh restart # Restart all services
./scripts/deploy.sh status # Show status
./scripts/deploy.sh logs # View admin_ui logs
./scripts/deploy.sh logs transit_api # View specific service logs
```
### Manage Services (Docker Compose)
```bash
# Start services
docker-compose up -d
# Stop services
docker-compose down
# Restart services
docker-compose restart
# Remove containers and volumes
docker-compose down -v
```
### Individual Services
```bash
# Restart a specific service
docker-compose restart auth_api
docker-compose restart admin_ui
# Build a specific image
docker-compose build auth_api
# Tail logs of a specific service
docker-compose logs -f transit_api
```
### Admin UI
```bash
# Access Admin UI in browser
http://localhost:5174
# View logs
docker-compose logs -f admin_ui
# Interactive shell in container
docker-compose exec admin_ui sh
```
## Troubleshooting
### Port Already in Use
```bash
# Check which process or container is using the port
docker ps
# or
lsof -i :5000
# Modify the host port in docker-compose.yml (e.g. change "5000:5000" to "5010:5000")
```
### Database Connection Error
```bash
# Check database container status (if running local db)
docker-compose logs db
# Restart database
docker-compose restart db
```
### API Startup Issue
```bash
# Check service logs
docker-compose logs auth_api
# Verify environment variables in .env
# Verify database connection string and network reachability
```
### Admin UI Not Reachable
```bash
# Check Admin UI container status
docker-compose logs admin_ui
# Ensure port 5174 is not bound by another process
lsof -i :5174
```
## Admin Control Center
The Admin UI is a modern React + Vite application that provides administrative management tools.
### Features
- **Authentication**: JWT-based login with dev token helper
- **Stop Management**: Create and manage transit stops with geo-coordinates
- **Bus Lines**: Define bus routes with start/end stops and travel duration
- **Fleet Management**: Monitor fleet vehicles
- **Stop Linking**: Assign stops to bus lines with sequence ordering and ETA
### Access
```
URL: http://localhost:5174
```
### Deployment Configuration
Environment variables in `docker-compose.yml`:
- `VITE_AUTH_API`: URL to auth_api (default: http://auth_api:5000)
- `VITE_TRANSIT_API`: URL to transit_api (default: http://transit_api:5001)
For external access, update URLs to your deployment domain.
### Build and Deployment
```bash
# Build Admin UI image
docker-compose build admin_ui
# Push to container registry
./scripts/build-and-push.sh ghcr.io/your-org/xtransit latest
# Deploy with updated image
docker-compose up -d
```
## Development
### Applying Code Changes
```bash
# Rebuild container
docker-compose build auth_api
# Restart service
docker-compose restart auth_api
```
### Code Hot-Reloading
For live reloading during development, you can mount source directories as volumes in `docker-compose.yml`:
```yaml
volumes:
- ./auth_api:/app
```
Note: This may impact performance on some host platforms. Use during local development only.
## Production Tips
1. **Security**: Change all default passwords and `JWT_SECRET` in `.env`.
2. **Database**: Use a managed database service or secured host in production.
3. **Logging**: Configure centralized logging via `error_log_api` or log aggregators.
4. **Backups**: Configure automated backup procedures for PostgreSQL.
5. **Health Checks**: Monitor the `/health` endpoints of all deployed APIs.
6. **SSL/TLS**: Use a reverse proxy (Nginx, Traefik) to provide HTTPS.
## Networking
Services communicate internally via:
- auth_api: `http://auth_api:5000`
- employee_api: `http://employee_api:4000`
- transit_api: `http://transit_api:5001`
- database: `db:5432` or external host
+55
View File
@@ -0,0 +1,55 @@
# XTransit Domains
This document is the central reference for the public XTransit hostnames.
Use HTTPS for every public request.
## Public Domains
| Domain | Service | Purpose |
| -------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `https://auth.xtransit.testinstance.nl` | Auth API | Driver and administrator login. Issues and validates JWT authentication tokens. Use this domain only for authentication requests such as `POST /login`. |
| `https://employees.xtransit.testinstance.nl` | Employee API | Employee onboarding and employee management. |
| `https://transit.xtransit.testinstance.nl` | Transit API | Transit and schedule data used by the Windows XP driver client, admin UI, and public timetable clients. This includes assigned daily schedules, weekly templates, shifts, bus lines, stops, routes, and timetable views. |
| `https://error.xtransit.testinstance.nl` | Error Log API | Central error collection and administration. Services send errors to `POST /error_log`; administrators can inspect them through `GET /error_log`. |
| `https://admin.xtransit.testinstance.nl` | Admin panel | Browser-based administration interface for managing bus lines, stops, vehicles, weekly schedules, driver shifts, and error logs. |
## Client Rules
### Windows XP driver client
The XP client must use:
- Auth and login: `https://auth.xtransit.testinstance.nl`
- Assigned schedules and route stops: `https://transit.xtransit.testinstance.nl`
The XP client must never use the auth domain for transit data.
### Admin UI and public timetable
The admin panel is available at `https://admin.xtransit.testinstance.nl`.
The admin UI and public timetable use:
- Authentication: `https://auth.xtransit.testinstance.nl`
- Transit API: `https://transit.xtransit.testinstance.nl`
- Error logging: `https://error.xtransit.testinstance.nl`
### Backend services
The backend services report errors to:
```text
https://error.xtransit.testinstance.nl/error_log
```
The database and internal Docker service name are deployment details and should not
be exposed as public client URLs. Public clients must use the HTTPS domains above.
## Quick Reference
```text
auth.xtransit.testinstance.nl Authentication and JWT tokens
employees.xtransit.testinstance.nl Employee management
transit.xtransit.testinstance.nl All transit and schedule data
error.xtransit.testinstance.nl Central error logging
admin.xtransit.testinstance.nl Browser-based administration panel
```
+281
View File
@@ -0,0 +1,281 @@
# 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:
1. Are the services reachable right now?
2. What kind of data does each service provide?
3. Which errors and bad requests have been recorded?
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.
## 1. What It Monitors
| Surface | Default URL | Health request | Purpose |
| ------------- | -------------------------------------------- | -------------- | --------------------------------------------------------- |
| Auth API | `http://localhost:5000` | `GET /me` | Login, JWT sessions, and current-user information |
| 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 |
| Error Log API | `http://localhost:5003` | `GET /health` | Centralized service errors and bad requests |
| Admin UI | `http://localhost:5174` | `GET /health` | Availability of the deployed React administration console |
The URLs are development defaults only. For a deployed dashboard, use the Connections screen or build-time `VITE_*` variables.
### Health semantics
- `healthy`: the request returned a successful HTTP status.
- `degraded`: the service answered, but returned a non-success status such as `401`, `403`, or `503`.
- `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.
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. Admin UI health is supplied by the nginx configuration in `admin_ui/nginx.conf` and returns a small JSON document.
## 2. Features
### Overview
The Overview tab contains one card per monitored surface. Each card shows:
- current status;
- response time for the latest poll;
- a compact latency history from recent polls;
- the returned health payload.
The top-level pulse shows how many surfaces are healthy and the average latency across completed checks. Polling can be set to 15 seconds, 30 seconds, or one minute.
### Service catalog
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
The Errors & bad requests tab reads:
```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
The Connections tab edits the five service base URLs and the optional admin JWT. Values are stored in `localStorage` under:
- `transit-monitor-urls`
- `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
```bash
cd monitoring_ui
npm install
npm run dev
```
Open the URL printed by Vite, normally `http://localhost:5173`.
Build and preview the production bundle:
```bash
npm run build
npm run preview
```
The app uses React 18 and Vite 5, matching the existing admin UI's frontend generation approach. Its main files are:
```text
monitoring_ui/
├── index.html browser entry point
├── package.json scripts and dependencies
├── vite.config.mjs Vite React configuration
├── src/
│ ├── main.jsx React bootstrap
│ ├── App.jsx service model, polling, tabs, request lab
│ └── styles.css responsive control-room styling
└── README.md short navigation pointer
```
## 4. Configuration
### Build-time environment variables
Create `monitoring_ui/.env.local` for a deployed or non-default environment:
```env
VITE_AUTH_API=https://auth.example.test
VITE_EMPLOYEE_API=https://employees.xtransit.testinstance.nl
VITE_TRANSIT_API=https://transit.example.test
VITE_ERROR_LOG_API=https://error.example.test
VITE_ADMIN_UI=https://admin.example.test
```
Vite reads these variables when the app is built. They are not secrets. Do not put JWTs, database passwords, signing keys, or API credentials in `.env.local` values that will be shipped to the browser.
The Connections screen is useful when the same build needs to point at different environments. Its values override the initial defaults in the current browser only.
### CORS requirements
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:
- `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.
## 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 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:
- never commit `.env.local` or copied tokens;
- avoid putting production JWTs in screenshots, issue reports, or browser history;
- 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;
- 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
### Static hosting
The dashboard can be hosted as static files from any web server that supports SPA fallback to `index.html`. Build it with:
```bash
npm run build
```
Publish `monitoring_ui/dist`. Configure the web server to serve `index.html` for unknown application paths. The current dashboard uses client-side tab state and does not require route rewrites beyond the root page, but SPA fallback remains a sensible default.
### Container hosting
The repository includes a Node build stage followed by nginx in `monitoring_ui/Dockerfile`. Runtime configuration is written to `env-config.js` by `monitoring_ui/docker-entrypoint.sh`, so the same image can be promoted between environments without baking service URLs into the JavaScript bundle.
The root Compose stack deploys this image as `monitoring_ui` on host port 5175. Set these Compose variables when deploying:
```env
AUTH_API=https://auth.xtransit.testinstance.nl
EMPLOYEE_API=https://employees.xtransit.testinstance.nl
TRANSIT_API=https://transit.xtransit.testinstance.nl
ERROR_LOG_API=https://error.xtransit.testinstance.nl
ADMIN_UI=https://admin.xtransit.testinstance.nl
```
The Gitea workflow detects changes under `monitoring_ui/**`, builds `monitoring_ui/Dockerfile`, and pushes the image as `monitoring_ui:<branch>` and `monitoring_ui:<commit>`. The `dev` branch includes the image in the Portainer redeploy dependency chain.
### Reverse proxy
A reverse proxy can place the dashboard and service domains behind one TLS boundary. This is often preferable for production because it simplifies CORS and gives operators a single origin. It is not required for local development.
## 7. Troubleshooting
### All cards show offline
1. Check that the API processes are running.
2. Check the URL in Connections, including protocol and port.
3. Open the health URL directly in a browser or use `curl`.
4. Inspect the browser console for CORS or TLS errors.
5. Confirm that Docker port mappings match `docker-compose.yml`.
### 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.
### 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
Check that the deployed image includes the CORS middleware from `employee_api/index.js`. A stale container image may still be running an earlier version.
### Admin UI health fails
Rebuild the admin UI image after adding `admin_ui/nginx.conf`. The endpoint is:
```bash
curl -i http://localhost:5174/health
```
Expected body:
```json
{ "status": "ok", "service": "admin_ui" }
```
### Health is slow
The dashboard measures full browser request time, including DNS, TLS, network, and API processing. Compare the dashboard value with a server-side curl from the same network before diagnosing application latency.
## 8. Extending the Dashboard
To add a service:
1. Add its base URL to `configured`.
2. Add its metadata to `serviceMeta`.
3. Add a health path to `healthPaths`.
4. Add representative safe routes to `testerRoutes`.
5. Add its environment variable to this document and `.env.local` examples.
6. Confirm the service has CORS headers and an appropriate health endpoint.
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.
Useful future extensions include:
- a time-window chart backed by a real metrics store;
- service version/build information in health payloads;
- threshold-based alerts for latency and repeated failures;
- export of incident rows for an operator handoff;
- 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
Before shipping a dashboard change:
```bash
cd monitoring_ui
npm install
npm run build
```
Then verify in a browser:
- all five cards render;
- one healthy local service shows a payload and latency;
- an unavailable service is clearly marked offline;
- Connections persists URL changes after reload;
- an admin JWT loads Error Log API data;
- an invalid token produces a visible error state;
- each Request lab route targets the expected service;
- the layout remains usable at a narrow mobile width.
+169
View File
@@ -0,0 +1,169 @@
# Transit Project Guide
This repository contains the XTransit service group, its React administration console, the monitoring dashboard, and a retained legacy client.
## Repository Map
| Directory | Responsibility | Default port |
| ----------------------- | --------------------------------------------------- | -----------: |
| `auth_api/` | Authentication and JWT issuance | 5000 |
| `employee_api/` | Employee onboarding and management | 4000 |
| `transit_api/` | Routes, schedules, fleet, GPS, and admin operations | 5001 |
| `error_log_api/` | Central error collection and protected log reads | 5003 |
| `admin_ui/` | React + Vite operations console served by nginx | 5174 |
| `monitoring_ui/` | React + Vite service monitoring dashboard | 5173 in dev |
| `deprecated/` | Retained Flutter bus-terminal client | n/a |
| `windows_xp_driver_ui/` | Legacy driver UI work | n/a |
Public domains and their intended roles are listed in [DOMAINS_README.md](../DOMAINS_README.md).
## Prerequisites
- Git
- Node.js 18+
- npm
- Docker 20.10+ and Docker Compose v2 for container workflows
- PostgreSQL for non-mock API development
- access to the configured database schemas and service credentials
## Local API Development
Each Node service has its own `package.json` and starts independently:
```bash
npm --prefix ./auth_api install
npm --prefix ./employee_api install
npm --prefix ./transit_api install
npm --prefix ./error_log_api install
```
The services read database and JWT settings from environment variables. Keep credentials in an ignored `.env` file or your secret manager. Never commit database passwords, JWT signing keys, or production tokens.
Transit API development can use `DEV_MOCK=1` for in-memory mock storage where supported. Mock mode is for development and should not be used as a production health signal.
## Docker and Deployment
The root `docker-compose.yml` pulls service images from the configured registry and expects an external PostgreSQL instance. Provide a server-side `.env`, then:
```bash
docker compose --env-file .env pull
docker compose --env-file .env up -d
```
Useful operations:
```bash
docker compose ps
docker compose logs -f auth_api
docker compose logs -f transit_api
docker compose up -d --force-recreate
docker compose down
```
See [DOCKER_README.md](../DOCKER_README.md), [DEPLOYMENT.md](../DEPLOYMENT.md), and [CI/CD and Portainer](../CI_CD_PORTAINER_GUIDE.md) for infrastructure-specific procedures.
The current compose stack exposes:
- `auth_api` on port 5000;
- `employee_api` on port 4000;
- `transit_api` on port 5001;
- `error_log_api` on port 5003;
- `admin_ui` on port 5174;
- `monitoring_ui` on port 5175.
The monitoring dashboard is currently a separate Vite application. Its full operation and deployment notes are in [MONITORING_UI.md](MONITORING_UI.md).
Its image is built and pushed by the Gitea workflow when `monitoring_ui/**`, `docker-compose.yml`, or the workflow changes.
## Environment Variables
The exact variable names are defined by `docker-compose.yml` and each service's database module. Common deployment values include:
```env
POSTGRES_HOST=your.db.host
POSTGRES_PORT=5432
POSTGRES_DB=transit_db
POSTGRES_USER_AUTH=auth_user
POSTGRES_PASSWORD_AUTH=strong_password
POSTGRES_USER_EMPLOYEE=employee_user
POSTGRES_PASSWORD_EMPLOYEE=strong_password
POSTGRES_USER_TRANSIT=transit_user
POSTGRES_PASSWORD_TRANSIT=strong_password
POSTGRES_USER_LOGGER=error_logger
POSTGRES_PASSWORD_LOGGER=strong_password
JWT_SECRET=replace-with-a-secret
ADMIN_USER_CODES=42,101
ERROR_LOG_API_URL=https://error.example.test/error_log
```
Use the names required by the active compose file. Do not copy these placeholders into a production environment without replacing them.
## Database Prerequisites
The APIs expect their configured schemas and tables to exist. The central error logger requires at least:
```sql
CREATE SCHEMA IF NOT EXISTS data;
CREATE TABLE IF NOT EXISTS data.errors (
id BIGSERIAL PRIMARY KEY,
"date" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
service TEXT NOT NULL,
partofservice TEXT,
error TEXT NOT NULL
);
```
Use separate database users with the minimum required permissions. The exact transit, route, worker, and fleet schemas are part of the deployment database setup rather than this application repository.
## API Health and Debugging
The primary health requests are:
```text
GET http://localhost:4000/health
GET http://localhost:5001/health
GET http://localhost:5003/health
GET http://localhost:5174/health
```
Auth API currently uses `GET /me`, which requires a JWT. The monitoring dashboard documents the distinction between process health and authenticated health in [MONITORING_UI.md](MONITORING_UI.md).
When a service fails:
1. Check container status and logs.
2. Check the service's environment variables.
3. Check database network connectivity and credentials.
4. Check that required schemas and permissions exist.
5. Call the health endpoint directly.
6. Check the central Error Log API if the service was able to report its failure.
## Admin UI
The admin UI manages stops, bus lines, fleet, schedules, and timetable views. Its API and schedule-specific documentation remains next to the source in `admin_ui/`. The nginx image now exposes `/health` as JSON for the monitoring dashboard.
Build it locally with:
```bash
cd admin_ui
npm install
npm run build
npm run dev
```
## CI and Registry
The GitHub Actions workflow builds service images and pushes them to the configured container registry. Registry authentication requires a token with the appropriate package permissions. Keep registry credentials outside source control.
## Security Rules
- keep secrets in ignored environment files or a secret manager;
- use TLS at public boundaries;
- use least-privilege database users;
- restrict CORS origins for production deployments;
- keep admin JWTs out of logs and screenshots;
- review allowlisted routes before expanding the monitoring request lab;
- treat `DEV_MOCK=1` as a local development setting only.
## Documentation Policy
Use `docs/` for project-wide operational and architectural guidance. Keep service-specific API details next to the service when they are maintained with that service. The root `README.md` should remain a concise orientation page with links, not a second deployment manual.
+29
View File
@@ -0,0 +1,29 @@
# Transit Project Documentation Hub
This directory is the consolidated documentation hub for the Transit Project. All system documentation, operational guides, and service specifications live here inside `monitoring_ui/docs/`.
## Core Guides
- [Project Guide](PROJECT_GUIDE.md): Architecture, local development, environment variables, Docker, deployment, database prerequisites, and troubleshooting.
- [Monitoring UI Guide](MONITORING_UI.md): Complete operations manual for `monitoring_ui`.
- [Root Readme](ROOT_README.md): Original main overview page.
- [Getting Started Guide](GETTING_STARTED.md): 5-minute setup & deployment quickstart.
- [Deployment Guide](DEPLOYMENT.md): Production deployment instructions & DB setup.
- [CI/CD & Portainer Guide](CI_CD_PORTAINER_GUIDE.md): Automation, registry, and Portainer stack deployment.
- [Admin Deployment Summary](ADMIN_DEPLOYMENT_SUMMARY.md): Deployment status and deliverable notes.
- [Docker Notes](DOCKER_README.md): Docker and Docker Compose workflows.
- [Domains Overview](DOMAINS_README.md): Domain mappings and endpoint list.
- [Security & Private Files](SECURITY_PRIVATE_FILES.md): Security practices & credential safety rules.
- [Developer Handoff](handoff.md): Engineering handoff notes.
- [Azure Project Plan](azure-project-plan.md): Azure migration and project planning notes.
## Service Documentation
- [Admin UI Documentation](admin_ui/README.md)
- [Admin Schedule API Guide](admin_ui/SCHEDULE_API.md)
- [Enhanced Schedule Maker](admin_ui/SCHEDULE_MAKER_ENHANCED.md)
- [Transit API Documentation](transit_api/README.md)
- [Local Bus Timetable Spec](transit_api/LOCAL_BUS_TIMETABLE_README.md)
- [Windows XP Driver UI Documentation](windows_xp_driver_ui/README.md)
- [Deprecated Flutter Client Documentation](deprecated/README.md)
+52
View File
@@ -0,0 +1,52 @@
# Transit Project Overview
This document provides the root overview for the Transit Project platform, maintained within the documentation hub.
For the primary repository landing page, see the root [`../../README.md`](../../README.md).
For the complete documentation index, see [`README.md`](README.md).
## System Architecture
XTransit is a service-oriented transit platform consisting of Node.js microservices, React operator interfaces, and retained legacy clients:
- `auth_api/` — Authentication, JWT token issuance, and admin role verification (port 5000)
- `employee_api/` — Employee management and onboarding (port 4000)
- `transit_api/` — Schedules, routes, fleet, GPS tracking, and transit administration (port 5001)
- `error_log_api/` — Centralized error collection and protected log access (port 5003)
- `admin_ui/` — React administration console (port 5174)
- `monitoring_ui/` — React service monitoring dashboard and documentation hub (port 5175 / dev 5173)
- `windows_xp_driver_ui/` — Native Win32 driver client targeting Windows XP-era systems
- `deprecated/` — Legacy Flutter bus terminal retained for reference
Public employee API: `https://employees.xtransit.testinstance.nl`
Public admin panel: `https://admin.xtransit.testinstance.nl`
Public error logging: `https://error.xtransit.testinstance.nl`
Public transit API: `https://transit.xtransit.testinstance.nl`
## Core Documentation
- [Project Guide](PROJECT_GUIDE.md) — Setup, architecture, environment variables, Docker, deployment, databases, and troubleshooting
- [Monitoring UI Guide](MONITORING_UI.md) — Complete `monitoring_ui` operations and development manual
- [Domains Overview](DOMAINS_README.md) — Public domain and service mapping
- [Docker Notes](DOCKER_README.md) — Container workflow and compose configurations
- [Deployment Guide](DEPLOYMENT.md) — Production deployment procedures
- [CI/CD & Portainer Guide](CI_CD_PORTAINER_GUIDE.md) — Image builds, registry, and stack operations
- [Security & Private Files](SECURITY_PRIVATE_FILES.md) — Security practices & credential safety rules
## Quick Start
Run the complete service stack using Docker Compose:
```bash
docker compose --env-file .env up -d
```
Run the monitoring dashboard locally:
```bash
cd monitoring_ui
npm install
npm run dev
```
Keep database credentials, JWT secrets, registry credentials, and tokens outside version control. Use environment variables, ignored local files, Portainer secrets, or another secret manager.
@@ -1,5 +1,27 @@
## Weekly schedules and shifts
The schedule manager uses the weekly template model. Administrators create a
departure for a bus line in one of three categories: weekdays, Saturday, or
Sunday. The UI creates the corresponding `day_of_week` rows and assigns drivers
through `transit.shifts`, rather than storing a driver on `transit.schedules`.
The active UI uses these transit API endpoints:
- `GET /admin/weekly-schedules`
- `GET /admin/shift-drivers`
- `POST /admin/weekly-schedules`
- `PUT /admin/weekly-schedules/:scheduleId/shift`
- `DELETE /admin/weekly-schedules/:scheduleId`
# Transit Admin Control Center # Transit Admin Control Center
Production admin panel: `https://admin.xtransit.testinstance.nl`
For the separate service monitoring dashboard, see
[`../MONITORING_UI.md`](../MONITORING_UI.md). The monitoring dashboard
is deployed independently on host port `5175` and checks this admin UI through
`GET /health`.
Modern React + Vite web application for comprehensive transit system administration. Modern React + Vite web application for comprehensive transit system administration.
## Features ## Features
@@ -15,6 +37,7 @@ Modern React + Vite web application for comprehensive transit system administrat
## Local Development ## Local Development
### Prerequisites ### Prerequisites
- Node.js 18+ - Node.js 18+
- npm or yarn - npm or yarn
@@ -30,9 +53,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
@@ -86,10 +109,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
@@ -98,6 +121,7 @@ When running in Docker, environment variables are injected at runtime via `docke
## Architecture ## Architecture
### Technology Stack ### Technology Stack
- **Frontend**: React 18.2 + Vite 5.0 - **Frontend**: React 18.2 + Vite 5.0
- **HTTP Client**: Axios 1.4 - **HTTP Client**: Axios 1.4
- **Server**: Nginx (Alpine Linux) - **Server**: Nginx (Alpine Linux)
@@ -116,6 +140,7 @@ When running in Docker, environment variables are injected at runtime via `docke
### API Integration ### API Integration
The app communicates with: The app communicates with:
- **Auth API** (/login, /me) - **Auth API** (/login, /me)
- **Transit API** (/admin/stops, /admin/bus-lines, /admin/fleet/buses) - **Transit API** (/admin/stops, /admin/bus-lines, /admin/fleet/buses)
- **Error Log API** (/error_log) - **Error Log API** (/error_log)
@@ -148,16 +173,19 @@ Users must have a `userCode` in their JWT and, for admin access, the `isAdmin` f
## Troubleshooting ## Troubleshooting
### Issue: "Admin UI not loading" ### Issue: "Admin UI not loading"
- Verify container is running: `docker ps` - Verify container is running: `docker ps`
- Check logs: `docker-compose logs admin_ui` - Check logs: `docker-compose logs admin_ui`
- Clear browser cache (Ctrl+Shift+R) - Clear browser cache (Ctrl+Shift+R)
### Issue: "API connection refused" ### Issue: "API connection refused"
- Ensure auth_api and transit_api are running - Ensure auth_api and transit_api are running
- Check environment variables are set correctly - Check environment variables are set correctly
- Verify network connectivity between containers - Verify network connectivity between containers
### Issue: "Permission denied" on admin operations ### Issue: "Permission denied" on admin operations
- Verify JWT token includes valid `userCode` - Verify JWT token includes valid `userCode`
- Check `ADMIN_USER_CODES` environment variable - Check `ADMIN_USER_CODES` environment variable
- Ensure database user has appropriate permissions - Ensure database user has appropriate permissions
@@ -180,6 +208,7 @@ See [DEPLOYMENT.md](../DEPLOYMENT.md) for comprehensive deployment guide.
## Support ## Support
For issues or questions: For issues or questions:
1. Check service logs: `docker-compose logs admin_ui` 1. Check service logs: `docker-compose logs admin_ui`
2. Verify API connectivity: Visit API URLs directly 2. Verify API connectivity: Visit API URLs directly
3. Check browser console for client-side errors 3. Check browser console for client-side errors
@@ -188,4 +217,3 @@ For issues or questions:
## License ## License
Part of the XTransit project Part of the XTransit project
@@ -4,66 +4,149 @@
The Transit API now includes a comprehensive schedule management system with both admin and public endpoints. The Transit API now includes a comprehensive schedule management system with both admin and public endpoints.
### Admin Endpoints (requires authentication + admin privileges) ### Weekly Admin Endpoints (requires authentication + admin privileges)
The active admin UI uses weekly schedule templates. `dayOfWeek` follows the
JavaScript/PostgreSQL convention: `0` Sunday, `1` through `5` weekdays, and `6`
Saturday. Driver assignment is stored in `transit.shifts` and references
`workers.users(id)`.
#### Get weekly schedules
#### Get all schedules
``` ```
GET /admin/schedules GET /admin/weekly-schedules
Response: { schedules: [...] } Response: { schedules: [...] }
``` ```
#### Create a schedule #### Get shift drivers
``` ```
GET /admin/shift-drivers
Response: { drivers: [{ id, name }] }
```
#### Create a weekly schedule
````
#### Create multiple departures
```text
POST /admin/weekly-schedules/bulk
Body: {
busLineId: 1,
dayOfWeeks: [1, 2, 3, 4, 5],
startTimes: ["06:00", "06:30", "07:00"],
driverId: 12
}
````
The API creates one schedule row for every day/time combination and derives the
end time from the bus line's configured duration.
POST /admin/weekly-schedules
Body: {
busLineId: 1,
dayOfWeek: 1,
startTime: "08:00",
endTime: "09:00",
driverId: 12
}
```
#### Replace a schedule's driver
```
PUT /admin/weekly-schedules/:scheduleId/shift
Body: { driverId: 12 }
```
Use `{ "driverId": null }` to unassign the driver. The current `shifts` table
does not contain a week/date column, so this is an active assignment rather than
historical per-week assignment. Add `week_start` to support that later.
### Legacy Admin Endpoints
#### Get all schedules
```
GET /admin/schedules
Response: { schedules: [...] }
```
#### Create a schedule
```
POST /admin/schedules POST /admin/schedules
Body: { Body: {
busLineId: number, busLineId: number,
busId: number, busId: number,
driverId: number (optional), driverId: number (optional),
startTime: "2026-05-06T08:00:00", startTime: "2026-05-06T08:00:00",
endTime: "2026-05-06T17:00:00" endTime: "2026-05-06T17:00:00"
} }
Response: { schedule: {...} } Response: { schedule: {...} }
``` ```
#### Delete a schedule #### Delete a schedule
``` ```
DELETE /admin/schedules/:scheduleId DELETE /admin/schedules/:scheduleId
Response: { success: true, schedule: {...} } Response: { success: true, schedule: {...} }
``` ```
### Public Endpoints (no authentication required) ### Public Endpoints (no authentication required)
#### Get schedules (with optional filters) #### Get schedules (with optional filters)
``` ```
GET /schedules?date=2026-05-06&lineId=1 GET /schedules?date=2026-05-06&lineId=1
Query params: Query params:
- date: YYYY-MM-DD (optional)
- lineId: number (optional) - date: YYYY-MM-DD (optional)
Response: { schedules: [...] } - lineId: number (optional)
Response: { schedules: [...] }
``` ```
#### Get schedule detail #### Get schedule detail
``` ```
GET /schedules/:scheduleId GET /schedules/:scheduleId
Response: { schedule: {...} } Response: { schedule: {...} }
``` ```
#### Get stops for a schedule's line #### Get stops for a schedule's line
``` ```
GET /schedules/:scheduleId/stops GET /schedules/:scheduleId/stops
Response: { stops: [...] } Response: { stops: [...] }
Shows all stops on the bus line with order, location, and ETA. Shows all stops on the bus line with order, location, and ETA.
```
````
## Admin UI Features ## Admin UI Features
The ScheduleMaker component provides: The ScheduleMaker component provides:
1. **Service Creation Form** 1. **Weekly schedule editor**
- Select bus line, vehicle, date, and time
- Optional driver ID assignment - Select a bus line and one of weekdays, Saturday, or Sunday
- Submit to create schedule - Define start and end times
- Admin-only access - Assign drivers through shifts
- Admin-only access
2. **Timetable View** 2. **Timetable View**
- 24-hour time slots (hourly grouping) - 24-hour time slots (hourly grouping)
@@ -86,12 +169,13 @@ The ScheduleMaker component provides:
The TimetableViewer component provides a public-facing schedule display: The TimetableViewer component provides a public-facing schedule display:
```jsx ```jsx
import TimetableViewer from './components/TimetableViewer' import TimetableViewer from "./components/TimetableViewer";
<TimetableViewer apiBaseUrl="http://api.transit.local:5001" /> <TimetableViewer apiBaseUrl="http://api.transit.local:5001" />;
``` ````
Features: Features:
- Filter by date and bus line - Filter by date and bus line
- Real-time loading state - Real-time loading state
- Error handling - Error handling
@@ -104,6 +188,7 @@ Features:
With `DEV_MOCK=1`, all schedule operations work in-memory without a database. With `DEV_MOCK=1`, all schedule operations work in-memory without a database.
Example: Example:
```bash ```bash
ADMIN_USER_CODES=42 DEV_MOCK=1 JWT_SECRET=devsecret npm --prefix ./transit_api start ADMIN_USER_CODES=42 DEV_MOCK=1 JWT_SECRET=devsecret npm --prefix ./transit_api start
``` ```
@@ -114,14 +199,19 @@ ADMIN_USER_CODES=42 DEV_MOCK=1 JWT_SECRET=devsecret npm --prefix ./transit_api s
CREATE TABLE transit.schedules ( CREATE TABLE transit.schedules (
id BIGINT PRIMARY KEY, id BIGINT PRIMARY KEY,
bus_line_id BIGINT REFERENCES transit.bus_lines(id), bus_line_id BIGINT REFERENCES transit.bus_lines(id),
bus_id BIGINT REFERENCES fleet.bus(id), start_time TIME,
driver_id INTEGER, end_time TIME,
start_time TIMESTAMP,
end_time TIMESTAMP,
status VARCHAR(20) DEFAULT 'planned', status VARCHAR(20) DEFAULT 'planned',
day_of_week SMALLINT NOT NULL CHECK (day_of_week BETWEEN 0 AND 6),
created_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW() updated_at TIMESTAMP DEFAULT NOW()
); );
CREATE TABLE transit.shifts (
id BIGINT PRIMARY KEY,
schedule_id BIGINT REFERENCES transit.schedules(id) ON DELETE CASCADE,
driver_id BIGINT REFERENCES workers.users(id)
);
``` ```
## Styling & UX ## Styling & UX
@@ -129,6 +219,7 @@ CREATE TABLE transit.schedules (
The schedule manager uses: The schedule manager uses:
### Admin Timetable Styling ### Admin Timetable Styling
- Responsive table with hourly time slots - Responsive table with hourly time slots
- Gradient schedule cards (blue/indigo theme) - Gradient schedule cards (blue/indigo theme)
- Hover effects and smooth transitions - Hover effects and smooth transitions
@@ -137,6 +228,7 @@ The schedule manager uses:
- Confirmation modals with semi-transparent overlay - Confirmation modals with semi-transparent overlay
### Public Timetable Styling ### Public Timetable Styling
- Clean, simple table layout - Clean, simple table layout
- Status badges (green for active, gray for planned) - Status badges (green for active, gray for planned)
- Mobile-responsive with scrolling - Mobile-responsive with scrolling
@@ -152,8 +244,9 @@ The schedule manager uses:
## Example Usage ## Example Usage
### 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 '{
@@ -166,13 +259,15 @@ 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
@@ -184,4 +279,3 @@ curl http://localhost:5001/schedules?lineId=3
- Schedule conflict detection - Schedule conflict detection
- Integration with GPS tracking for actual vs scheduled times - Integration with GPS tracking for actual vs scheduled times
- Historical schedule analytics - Historical schedule analytics
+199
View File
@@ -0,0 +1,199 @@
# Project Plan
**Status**: Planning
**Created**: 2026-09-20
**Mode**: NEW
---
## 1. Project Overview
**Goal**: Build a secure React + Vite operations console that monitors the existing XTransit APIs, summarizes service capabilities and errors, and provides a guarded live request tester. The project is designed so that every module is independently testable.
**App Type**: SPA + API
**API Login**: Yes
**Mode**: NEW
**Deployment Plan**: No deployment plan found
---
## 2. Monitoring Proxy API — backend
| Component | Technology |
| ------------------- | -------------- |
| **Language** | TypeScript |
| **Runtime** | Node |
| **Package Manager** | npm |
| **Test Runner** | vitest |
| **Mocking Library** | vi.mock |
| **Test Command** | npm test |
| **Orchestration** | docker-compose |
The proxy owns environment configuration, upstream health aggregation, the allowlisted endpoint catalog, authenticated request execution, error-log reads, response redaction, CORS, rate limiting, and normalized error responses. It must fail closed for unknown targets, unsafe methods, oversized bodies, and excessive timeouts.
## 3. Transit Monitor Dashboard — frontend
| Component | Technology |
| ------------------- | ------------ |
| **Language** | TypeScript |
| **Framework** | React + Vite |
| **Package Manager** | npm |
| **Test Runner** | vitest |
| **Mocking Library** | vi.mock |
| **Test Command** | npm test |
The dashboard consumes only the proxy API, keeps polling and response-history state in memory, and exposes environment selection, service health, capabilities, errors, and the guarded tester as focused operator workflows.
## 4. Services Required
| Azure Service | Role in App | Environment Variable | Default Value (Local) | Classification |
| ------------- | ------------------------------------------------------------------------------------------------------ | -------------------- | --------------------- | -------------- |
| None | The console reads existing XTransit APIs and does not add persistence, queues, cache, or file storage. | — | — | Enhancement |
## 5. Prerequisites
### Run
| Tool | Service(s) | Installed | Version |
| ---------- | ---------- | --------- | ----------------------------------- |
| Node.js | \* | ✅ | v22.22.1 |
| npm | \* | ✅ | 9.2.0 |
| TypeScript | \* | ❓ | Verify through project dependencies |
| Vitest | \* | ❓ | Verify through project dependencies |
### Debug
| Tool | Service(s) | Installed | Version |
| ------------------------ | ----------------------------------------------- | --------- | ----------------- |
| Docker | monitoring-proxy-api, transit-monitor-dashboard | ✅ | 29.1.3 |
| Docker Compose | monitoring-proxy-api, transit-monitor-dashboard | ✅ | v5.5.0 |
| VS Code Docker extension | monitoring-proxy-api, transit-monitor-dashboard | ❓ | Verify in VS Code |
| VS Code ESLint extension | monitoring-proxy-api, transit-monitor-dashboard | ❓ | Verify in VS Code |
Double-check all tools marked ❓ before proceeding.
## 6. Design System & UI
**Component Library**: Fluent UI v9
**Style Direction**: A calm, data-dense transit control room with a warm paper-white canvas, ink-black typography, cobalt actions, and signal colors reserved for service state. Use compact 4px control corners, crisp dividers, restrained elevation, and generous horizontal rhythm so operators can scan incidents without visual noise.
**Typography**: Segoe UI Variable, Segoe UI, sans-serif
### Color Palette
| Token | Hex | Usage |
| --------- | --------- | --------------------------------------------------------------------------- |
| `primary` | `#155EEF` | Operator actions, active dashboard navigation, and selected service filters |
| `accent` | `#F59E0B` | Watch states, response-time highlights, and attention markers |
| `surface` | `#F6F8FB` | Main console canvas and quiet page background |
| `text` | `#172033` | Service names, route labels, and incident details |
| `muted` | `#667085` | Polling timestamps, metadata, and supporting descriptions |
| `border` | `#D9E0EA` | Console dividers, table rules, and input outlines |
### Pages
| Page | Route | Purpose | Layout |
| --------------- | ------------ | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ------------------- |
| Command Center | `/` | See the current health picture, polling freshness, and the most important service signals. | `header, nav, sidebar, main, hero, grid, table, actions` |
| Service Catalog | `/services` | Inspect each upstream service's capabilities and representative route coverage. | `header, nav, sidebar, main, tabs, card-list, table, actions` |
| Request Lab | `/requests` | Run a bounded, authenticated request against an allowlisted upstream route and inspect timing. | `header, nav, sidebar, main, form, split(form | table), action-bar` |
| Incident Log | `/incidents` | Review normalized error-log activity and filter by service or severity. | `header, nav, sidebar, main, tabs, table, card-list` |
### Sample Content
Command Center — service health:
| Service | Endpoint | Latency | Status |
|---------|----------|---------|--------|
| Transit API | `/health` | 184 ms | Operational |
| Employee API | `/health` | 96 ms | Operational |
| Error Log API | `/health` | 121 ms | Degraded |
| Admin UI | `http://localhost:5173` | 42 ms | Operational |
Service Catalog — service capability:
| Service | Capability | Route | Access |
|---------|------------|-------|--------|
| Transit API | Daily schedules | `/schedule/daily` | Authenticated |
| Transit API | Route detail | `/routes/:routeId` | Authenticated |
| Employee API | Create employee | `/createemployee` | Admin |
| Error Log API | Read error log | `/error_log` | Admin |
Request Lab — request preset:
| Service | Method | Path | Expected result |
|---------|--------|------|-----------------|
| Transit API | GET | `/schedules/overview` | Schedule summary |
| Transit API | GET | `/admin/drivers` | Driver roster |
| Employee API | GET | `/health` | Service health |
Incident Log — normalized incident:
| Time | Service | Code | Message | Severity |
|------|---------|------|---------|----------|
| 09:42:18 | Error Log API | `BAD_REQUEST` | Missing route identifier | Warning |
| 09:37:04 | Transit API | `FORBIDDEN` | Admin role required | Info |
| 09:18:51 | Employee API | `INTERNAL_ERROR` | Employee lookup timed out | Critical |
## 7. Project Structure
```
monitoring_dashboard/
├── package.json
├── tsconfig.json
├── vite.config.ts
├── index.html
├── Dockerfile
├── docker-compose.yml
├── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── api/client.ts
│ ├── api/types.ts
│ ├── components/
│ │ ├── AppShell.tsx
│ │ ├── HealthSummary.tsx
│ │ ├── ServiceCard.tsx
│ │ ├── RequestForm.tsx
│ │ └── IncidentTable.tsx
│ ├── pages/
│ │ ├── CommandCenter.tsx
│ │ ├── ServiceCatalog.tsx
│ │ ├── RequestLab.tsx
│ │ └── IncidentLog.tsx
│ └── styles/
│ └── theme.css
└── server/
├── package.json
├── tsconfig.json
├── Dockerfile
└── src/
├── index.ts
├── config.ts
├── routes/health.ts
├── routes/catalog.ts
├── routes/requests.ts
├── routes/incidents.ts
├── middleware/auth.ts
├── middleware/rateLimit.ts
├── services/upstreamClient.ts
├── services/healthAggregator.ts
└── services/redaction.ts
```
## 8. Route Definitions
| # | Method | Path | Description | Request Body | Response Body | Status Codes |
| --- | ------ | --------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------- |
| 1 | GET | `/api/health` | Aggregate health for auth, employee, transit, error-log APIs, and admin UI. | — | `{ status, checkedAt, services: [{ id, status, latencyMs, endpoint }] }` | 200, 503 |
| 2 | GET | `/api/catalog` | Return the allowlisted service and endpoint capability catalog. | — | `{ services: [{ id, label, capabilities: [{ method, path, access }] }] }` | 200, 401 |
| 3 | GET | `/api/incidents` | Read normalized error activity from the error-log API with bounded filters. | — | `{ incidents: [{ id, occurredAt, service, code, message, severity }] }` | 200, 400, 401, 502 |
| 4 | POST | `/api/requests/execute` | Execute one authenticated request against an allowlisted upstream route with safe limits. | `{ serviceId, method, path, query, body, timeoutMs }` | `{ requestId, status, durationMs, headers, data, redactions }` | 200, 400, 401, 403, 408, 429, 502 |
| 5 | GET | `/api/requests/history` | Return in-memory request timing and status history for the current process. | — | `{ items: [{ requestId, serviceId, method, path, status, durationMs, executedAt }] }` | 200, 401 |
| 6 | GET | `/api/config/environments` | List configured environment names without exposing secrets. | — | `{ environments: [{ id, label, active }] }` | 200, 401 |
| 7 | POST | `/api/config/environments/select` | Select a configured upstream environment for the signed-in operator session. | `{ environmentId }` | `{ environmentId, label, selectedAt }` | 200, 400, 401, 404 |
## 9. Next Steps
1. Run **azure-project-scaffold** to execute this plan
2. Run **azure-project-integrate** to wire the frontend to live data, smoke-test the backend, and create the migrations
3. Run **azure-debug-plan** → **azure-debug-generate** for Docker emulators and VS Code debugging
4. Run the **azure-deploy** agent when ready; it uses **azure-app-onboard** for architecture, cost estimation, IaC generation, provisioning, and health verification
@@ -2,6 +2,11 @@
This Flutter application is retained as a legacy reference implementation while the transit client moves away from the Android-era setup toward a Windows XP-era compatibility target. This Flutter application is retained as a legacy reference implementation while the transit client moves away from the Android-era setup toward a Windows XP-era compatibility target.
It is not part of the current monitoring or deployment path. Current service
and dashboard documentation is maintained in
[`../PROJECT_GUIDE.md`](../PROJECT_GUIDE.md) and
[`../MONITORING_UI.md`](../MONITORING_UI.md).
## Status ## Status
- Deprecated: yes - Deprecated: yes
@@ -1,41 +1,41 @@
# Transit API # Transit API
Transit API voor het beheren van busroutes, navigatie en GPS tracking voor busbestuurders. Transit API for managing bus routes, navigation, and GPS tracking for bus drivers.
## Project Structuur ## Project Structure
``` ```
transit_api/ transit_api/
├── index.js # Main API endpoints ├── index.js # Main API endpoints
├── middleware.js # JWT authentication middleware ├── middleware.js # JWT authentication middleware
├── db.js # PostgreSQL connection ├── db.js # PostgreSQL connection
├── schema.sql # Database schema setup ├── schema.sql # Database schema setup
├── package.json # Dependencies ├── package.json # Dependencies
└── .env # Environment variables └── .env # Environment variables
``` ```
## Setup ## Setup
### 1. Dependencies installeren ### 1. Install Dependencies
```bash ```bash
npm install npm install
``` ```
### 2. Database Schema opzetten ### 2. Set Up Database Schema
Voer het volgende uit in PostgreSQL: Execute the following in PostgreSQL:
```bash ```bash
# Via psql command line # Via psql command line
psql -U your_user -d your_database -f schema.sql psql -U your_user -d your_database -f schema.sql
``` ```
Of kopieer-plak de SQL queries rechtstreeks in pgAdmin. Or copy-paste the SQL queries directly into pgAdmin.
### 3. Environment variabelen (.env) ### 3. Environment Variables (.env)
``` ```env
PG_HOST=localhost PG_HOST=localhost
PG_PORT=5432 PG_PORT=5432
PG_USER=your_user PG_USER=your_user
@@ -47,23 +47,81 @@ PORT=5001
Note: `ADMIN_USER_CODES` has been moved to the `auth_api` service. Configure admin user codes in `auth_api/.env` as a comma-separated list. Note: `ADMIN_USER_CODES` has been moved to the `auth_api` service. Configure admin user codes in `auth_api/.env` as a comma-separated list.
### 4. API starten ### 4. Start API
```bash ```bash
npm start npm start
``` ```
API draait op `http://localhost:5001` The API runs at `https://transit.xtransit.testinstance.nl`
The service group also includes the Employee API at
`https://employees.xtransit.testinstance.nl` and the central monitoring
dashboard. See [`../MONITORING_UI.md`](../MONITORING_UI.md) for health
checks, error inspection, and the controlled request tester.
## API Endpoints ## API Endpoints
Alle endpoints vereisen een `Authorization: Bearer {token}` header. All endpoints require an `Authorization: Bearer {token}` header.
**GET** `/health`
- Checks whether the deployed transit API is running and the database is reachable.
- Returns `200` with `{"status":"ok","database":"ok"}` on success.
- Returns `503` if the API is running but the database is unreachable.
### Schedule ### Schedule
Schedules are weekly templates. Each row in `transit.schedules` represents one
departure time for one bus line and one `day_of_week`:
- `1` to `5`: weekdays
- `6`: Saturday
- `0`: Sunday
Driver assignments are stored separately in `transit.shifts` with
`(schedule_id, driver_id)`, where `driver_id` references `workers.users(id)`.
The current `shifts` shape represents the active assignment. To retain different
assignments for different calendar weeks, add a `week_start` (or assignment date
range) column and include it in the unique key.
**GET** `/driver/schedules/daily?date=2026-09-18`
- Returns only weekly schedule templates assigned to the authenticated driver through `transit.shifts`.
**PATCH** `/driver/weekly-schedules/:scheduleId/status`
- Updates the status of an assigned weekly schedule.
**GET** `/admin/weekly-schedules`
- Lists weekly templates and their current driver assignments.
**GET** `/admin/shift-drivers`
- Lists drivers from `workers.users` for shift assignment.
**POST** `/admin/weekly-schedules`
- Creates one template. Body: `{ "busLineId": 1, "dayOfWeek": 1, "startTime": "08:00", "endTime": "09:00", "driverId": 12 }`.
**POST** `/admin/weekly-schedules/bulk`
- Creates multiple departures in one transaction. The API derives each end time
from the selected bus line duration. Body: `{ "busLineId": 1, "dayOfWeeks": [1,2,3,4,5], "startTimes": ["06:00", "06:30", "07:00"], "driverId": 12 }`.
**PUT** `/admin/weekly-schedules/:scheduleId/shift`
- Replaces the driver assignment. Pass `{ "driverId": 12 }` or `{ "driverId": null }`.
The legacy `/admin/schedules` and `/schedule/daily` endpoints remain for older
clients, but new clients must use the weekly schedule endpoints above.
**GET** `/schedule/daily?date=2024-05-05` **GET** `/schedule/daily?date=2024-05-05`
- Ophalen van dagplanning voor chauffeur
- Retrieve daily schedule for driver
- Response: - Response:
```json ```json
{ {
"date": "2024-05-05", "date": "2024-05-05",
@@ -88,30 +146,35 @@ Alle endpoints vereisen een `Authorization: Bearer {token}` header.
``` ```
**GET** `/routes/:routeId` **GET** `/routes/:routeId`
- Details van een specifieke route
- Details of a specific route
- Response: `{ "route": {...} }` - Response: `{ "route": {...} }`
### Public Schedule Overview ### Public Schedule Overview
**GET** `/schedules/overview?date=2026-05-06&lineId=1` **GET** `/schedules/overview?date=2026-05-06&lineId=1`
- Haalt een datum-gebonden overzicht op van geplande diensten
- Herhaalde diensten worden naar de geselecteerde datum gematerialiseerd via `weekdays` - Fetches a date-based overview of scheduled services
- Incidentele diensten worden alleen meegenomen op hun exacte datum - Weekly templates are selected by `day_of_week` and driver assignments by `shifts`
- One-off / incidental services are only included on their exact date
- Filters: `driverId`/`chauffeurId`, `stopId`/`busStopId`, `lineId`, `status` - Filters: `driverId`/`chauffeurId`, `stopId`/`busStopId`, `lineId`, `status`
- Response: `{ "date": "2026-05-06", "summary": {...}, "schedules": [...] }` - Response: `{ "date": "2026-05-06", "summary": {...}, "schedules": [...] }`
De legacy endpoint **GET** `/schedules` blijft beschikbaar voor bestaande clients. The legacy endpoint **GET** `/schedules` remains available for existing clients.
Example:
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
**POST** `/navigation/route` **POST** `/navigation/route`
- Opslaan van berekende route van OSRM
- Save calculated route from OSRM
- Body: - Body:
```json ```json
{ {
"routeId": 1, "routeId": 1,
@@ -129,8 +192,10 @@ curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&bu
``` ```
**GET** `/bus-lines/:busLineId/stops` **GET** `/bus-lines/:busLineId/stops`
- Alle stops van een buslijn (voor navigatie waypoints)
- All stops of a bus line (for navigation waypoints)
- Response: - Response:
```json ```json
{ {
"stops": [ "stops": [
@@ -138,8 +203,8 @@ curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&bu
"id": 1, "id": 1,
"order": 1, "order": 1,
"name": "Central Station", "name": "Central Station",
"latitude": 52.5200, "latitude": 52.52,
"longitude": 13.4050 "longitude": 13.405
} }
] ]
} }
@@ -148,13 +213,15 @@ curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&bu
### GPS Tracking ### GPS Tracking
**POST** `/gps/track` **POST** `/gps/track`
- Real-time positie opslaan
- Save real-time position
- Body: - Body:
```json ```json
{ {
"routeId": 1, "routeId": 1,
"latitude": 52.5210, "latitude": 52.521,
"longitude": 13.4060, "longitude": 13.406,
"speed": 45.5, "speed": 45.5,
"heading": 180, "heading": 180,
"accuracy": 5.0 "accuracy": 5.0
@@ -162,15 +229,17 @@ curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&bu
``` ```
**GET** `/gps/route/:routeId` **GET** `/gps/route/:routeId`
- Alle opgeslagen positie punten van een route
- All recorded position points of a route
- Response: - Response:
```json ```json
{ {
"positions": [ "positions": [
{ {
"id": 1, "id": 1,
"latitude": 52.5210, "latitude": 52.521,
"longitude": 13.4060, "longitude": 13.406,
"speed": 45.5, "speed": 45.5,
"heading": 180, "heading": 180,
"recordedAt": "2024-05-05T08:15:00Z" "recordedAt": "2024-05-05T08:15:00Z"
@@ -182,28 +251,33 @@ curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&bu
### Route Status ### Route Status
**PATCH** `/routes/:routeId/status` **PATCH** `/routes/:routeId/status`
- Update route status - Update route status
- Body: `{ "status": "in_progress" }` - Body: `{ "status": "in_progress" }`
- Valid statuses: `scheduled`, `in_progress`, `completed`, `cancelled` - Valid statuses: `scheduled`, `in_progress`, `completed`, `cancelled`
### Admin ### Admin
Alle admin-endpoints vereisen naast een geldig JWT-token ook dat je `userCode` voorkomt in `ADMIN_USER_CODES`. All admin endpoints require, in addition to a valid JWT token, that your `userCode` is present in `ADMIN_USER_CODES`.
**POST** `/admin/stops` **POST** `/admin/stops`
- Maak een herbruikbare stop aan
- Create a reusable stop
- Body: - Body:
```json ```json
{ {
"name": "Central Station", "name": "Central Station",
"latitude": 52.5200, "latitude": 52.52,
"longitude": 13.4050 "longitude": 13.405
} }
``` ```
**POST** `/admin/bus-lines/:busLineId/stops` **POST** `/admin/bus-lines/:busLineId/stops`
- Koppel een bestaande stop aan een buslijn
- Link an existing stop to a bus line
- Body: - Body:
```json ```json
{ {
"stopId": 1, "stopId": 1,
@@ -215,12 +289,14 @@ Alle admin-endpoints vereisen naast een geldig JWT-token ook dat je `userCode` v
## Database Schema ## Database Schema
### bus_types ### bus_types
- `id` - Primary key - `id` - Primary key
- `name` - Bus type name (Standard Bus, etc.) - `name` - Bus type name (Standard Bus, etc.)
- `seat_capacity` - Number of seats - `seat_capacity` - Number of seats
- `license_plate` - Unique license plate - `license_plate` - Unique license plate
### bus_lines ### bus_lines
- `id` - Primary key - `id` - Primary key
- `line_number` - Unique line number (1, 5, 12, etc.) - `line_number` - Unique line number (1, 5, 12, etc.)
- `start_stop` - Start stop name - `start_stop` - Start stop name
@@ -228,11 +304,13 @@ Alle admin-endpoints vereisen naast een geldig JWT-token ook dat je `userCode` v
- `estimated_duration_minutes` - Expected route duration - `estimated_duration_minutes` - Expected route duration
### stops ### stops
- `id` - Primary key - `id` - Primary key
- `name` - Stop name - `name` - Stop name
- `latitude`, `longitude` - Stop location - `latitude`, `longitude` - Stop location
### route_stops ### route_stops
- `id` - Primary key - `id` - Primary key
- `bus_line_id` - Which bus line the stop belongs to - `bus_line_id` - Which bus line the stop belongs to
- `stop_id` - Reusable stop reference - `stop_id` - Reusable stop reference
@@ -240,28 +318,31 @@ Alle admin-endpoints vereisen naast een geldig JWT-token ook dat je `userCode` v
- `estimated_arrival_minutes` - Optional ETA per stop - `estimated_arrival_minutes` - Optional ETA per stop
### routes ### routes
- `id` - Primary key - `id` - Primary key
- `user_id` - Chauffeur (references workers.users) - `user_id` - Driver (references workers.users)
- `bus_line_id` - Which line (references bus_lines) - `bus_line_id` - Which line (references bus_lines)
- `bus_type_id` - Which bus (references bus_types) - `bus_type_id` - Which bus (references bus_types)
- `start_time` - Scheduled start - `start_time` - Scheduled start
- `status` - scheduled/in_progress/completed/cancelled - `status` - scheduled/in_progress/completed/cancelled
### navigation_routes ### navigation_routes
- `id` - Primary key - `id` - Primary key
- `route_id` - Link to route (unique) - `route_id` - Link to route (unique)
- `polyline_geojson` - GeoJSON LineString van de route - `polyline_geojson` - GeoJSON LineString of the route
- `total_distance_meters` - Route distance - `total_distance_meters` - Route distance
- `total_duration_seconds` - Estimated duration - `total_duration_seconds` - Estimated duration
### gps_tracking ### gps_tracking
- `id` - Primary key - `id` - Primary key
- `route_id` - Which route - `route_id` - Which route
- `latitude`, `longitude` - Current position - `latitude`, `longitude` - Current position
- `speed_kmh` - Current speed - `speed_kmh` - Current speed
- `recorded_at` - Timestamp - `recorded_at` - Timestamp
## Integration met Flutter App ## Integration with Flutter App
```dart ```dart
// In schedule_service.dart // In schedule_service.dart
@@ -286,7 +367,8 @@ await scheduleService.updateRouteStatus(1, 'in_progress');
## Error Handling ## Error Handling
Alle endpoints retourneren error responses: All endpoints return error responses:
```json ```json
{ {
"error": "error_code", "error": "error_code",
@@ -295,6 +377,7 @@ Alle endpoints retourneren error responses:
``` ```
Common errors: Common errors:
- `missing_token` - No Authorization header - `missing_token` - No Authorization header
- `invalid_token` - Token verification failed - `invalid_token` - Token verification failed
- `missing_required_fields` - Required data missing - `missing_required_fields` - Required data missing
@@ -0,0 +1,50 @@
# Transit Driver XP
Native Win32 driver client targeting Windows XP-era systems.
Project-wide setup and service documentation is maintained in
[`../PROJECT_GUIDE.md`](../PROJECT_GUIDE.md). The operational
monitoring dashboard is documented in
[`../MONITORING_UI.md`](../MONITORING_UI.md).
For Windows XP compatibility, the client temporarily uses the LAN services:
- Login: `http://192.168.68.66:5000/login`
- Assigned schedules: `http://192.168.68.66:5001/driver/schedules/daily`
- Route stops: `http://192.168.68.66:5001/bus-lines/:busLineId/stops`
This HTTP fallback avoids the TLS 1.2/certificate limitations of Windows XP.
Schedules are assigned through `transit.shifts`; the client does not expect a
`driver_id`, `bus_id`, or `weekdays` column on `transit.schedules`.
## Build
```bash
make clean && make
```
## Distribution
Build the installer with:
```bash
makensis installer.nsi
```
Distribute `TransitDriverXPSetup.exe`. It detects an existing installation,
stops the previous client, and replaces it in the same directory.
## Login diagnostics
If the app shows `Driver service unreachable`, open `driver_xp.log` in the same
folder as the installed `driver_xp.exe` and inspect the latest entries. The log
records the failing stage and WinHTTP or HTTP status code, but never writes the
password or JWT token.
Common codes:
- `12007`: hostname could not be resolved (DNS)
- `12029`: connection to the server failed
- `12175`: HTTPS certificate or TLS validation failed
- `401` or `403`: the server responded, but authentication was rejected
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#101517" />
<title>Transit Control Room</title>
</head>
<body>
<div id="root"></div>
<script src="/env-config.js"></script>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
server {
listen 80;
root /usr/share/nginx/html;
location / {
try_files $uri $uri/ /index.html;
}
}
+1478
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "transit-monitoring-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@vitejs/plugin-react": "5.0.0",
"vite": "5.0.0",
"react": "18.2.0",
"react-dom": "18.2.0"
},
"devDependencies": {}
}
@@ -0,0 +1,47 @@
# Auth API
Authentication service for XTransit. Handles login, JWT token generation, and user verification.
## Setup
### Environment Variables
```env
PG_HOST=localhost
PG_PORT=5432
PG_USER=your_user
PG_PASS=your_password
PG_DB=your_database
JWT_SECRET=your_jwt_secret
PORT=5000
ADMIN_USER_CODES=1234,5678
DEV_BYPASS=0
ERROR_LOG_API_URL=https://error.xtransit.testinstance.nl/error_log
```
### Start API
```bash
npm start
```
Runs at `https://auth.xtransit.testinstance.nl`
## API Endpoints
**POST** `/login`
- Body: `{ "userCode": 1234, "password": "secret" }`
- Returns: `{ "token": "jwt...", "user": { "id": 1, "userCode": 1234, "job": "driver", "isAdmin": false } }`
**GET** `/me`
- Requires: `Authorization: Bearer <token>`
- Returns: `{ "user": { "userId": 1, "userCode": 1234, "isAdmin": false } }`
**GET** `/health`
- Returns: `{ "status": "ok", "service": "auth_api" }`
## Database
Uses `workers.users` table with columns: `id`, `usercode`, `password` (bcrypt), `job`
Admin status is determined by `ADMIN_USER_CODES` env variable (comma-separated user codes).
@@ -0,0 +1,39 @@
# Employee API
Employee management service for XTransit. Handles employee onboarding and records.
## Setup
### Environment Variables
```env
PG_HOST=localhost
PG_PORT=5432
PG_USER=your_user
PG_PASS=your_password
PG_DB=your_database
PORT=4000
ERROR_LOG_API_URL=https://error.xtransit.testinstance.nl/error_log
```
### Start API
```bash
npm start
```
Runs at `https://employees.xtransit.testinstance.nl`
## API Endpoints
**POST** `/createemployee`
- Body: `{ "firstname": "John", "lastname": "Doe", "job": "driver", "password": "secret" }`
- Creates employee in `workers.employees` and user in `workers.users`
- Returns: `{ "employee": "John", "code": "1234" }`
**GET** `/health`
- Returns: `{ "endpoint": "reachable", "database": "ok" }` or 503 if DB down
## Database
Uses `workers.employees` (id, firstname, lastname, job) and `workers.users` (id, employee, password, usercode)
@@ -0,0 +1,45 @@
# Error Log API
Centralized error logging service for XTransit. Collects errors from all services.
## Setup
### Environment Variables
```env
PG_HOST=localhost
PG_PORT=5432
PG_USER=your_user
PG_PASS=your_password
PG_DB=your_database
PORT=5003
JWT_SECRET=your_jwt_secret
```
### Start API
```bash
npm start
```
Runs at `https://error.xtransit.testinstance.nl`
## API Endpoints
**POST** `/error_log`
- Body: `{ "service": "transit_api", "partofservice": "/schedule/daily", "error": "DB connection failed" }`
- Logs error to database
- Returns: `{ "ok": true }`
**GET** `/error_log?limit=100&service=transit_api`
- Requires: `Authorization: Bearer <admin_token>`
- Returns: `{ "errors": [...] }`
**GET** `/health`
- Returns: `{ "status": "ok", "service": "error_log_api", "database": "ok" }` or 503
## Database
Uses `data.errors` table (id, date, service, partofservice, error)
Admin access controlled via JWT `isAdmin` claim from auth_api.
@@ -0,0 +1,386 @@
# Transit API
Transit API for managing bus routes, navigation, and GPS tracking for bus drivers.
## Project Structure
```
transit_api/
├── index.js # Main API endpoints
├── middleware.js # JWT authentication middleware
├── db.js # PostgreSQL connection
├── schema.sql # Database schema setup
├── package.json # Dependencies
└── .env # Environment variables
```
## Setup
### 1. Install Dependencies
```bash
npm install
```
### 2. Set Up Database Schema
Execute the following in PostgreSQL:
```bash
# Via psql command line
psql -U your_user -d your_database -f schema.sql
```
Or copy-paste the SQL queries directly into pgAdmin.
### 3. Environment Variables (.env)
```env
PG_HOST=localhost
PG_PORT=5432
PG_USER=your_user
PG_PASS=your_password
PG_DB=your_database
JWT_SECRET=your_jwt_secret
PORT=5001
```
Note: `ADMIN_USER_CODES` has been moved to the `auth_api` service. Configure admin user codes in `auth_api/.env` as a comma-separated list.
### 4. Start API
```bash
npm start
```
The API runs at `https://transit.xtransit.testinstance.nl`
The service group also includes the Employee API at
`https://employees.xtransit.testinstance.nl` and the central monitoring
dashboard. See [`../MONITORING_UI.md`](../MONITORING_UI.md) for health
checks, error inspection, and the controlled request tester.
## API Endpoints
All endpoints require an `Authorization: Bearer {token}` header.
**GET** `/health`
- Checks whether the deployed transit API is running and the database is reachable.
- Returns `200` with `{"status":"ok","database":"ok"}` on success.
- Returns `503` if the API is running but the database is unreachable.
### Schedule
Schedules are weekly templates. Each row in `transit.schedules` represents one
departure time for one bus line and one `day_of_week`:
- `1` to `5`: weekdays
- `6`: Saturday
- `0`: Sunday
Driver assignments are stored separately in `transit.shifts` with
`(schedule_id, driver_id)`, where `driver_id` references `workers.users(id)`.
The current `shifts` shape represents the active assignment. To retain different
assignments for different calendar weeks, add a `week_start` (or assignment date
range) column and include it in the unique key.
**GET** `/driver/schedules/daily?date=2026-09-18`
- Returns only weekly schedule templates assigned to the authenticated driver through `transit.shifts`.
**PATCH** `/driver/weekly-schedules/:scheduleId/status`
- Updates the status of an assigned weekly schedule.
**GET** `/admin/weekly-schedules`
- Lists weekly templates and their current driver assignments.
**GET** `/admin/shift-drivers`
- Lists drivers from `workers.users` for shift assignment.
**POST** `/admin/weekly-schedules`
- Creates one template. Body: `{ "busLineId": 1, "dayOfWeek": 1, "startTime": "08:00", "endTime": "09:00", "driverId": 12 }`.
**POST** `/admin/weekly-schedules/bulk`
- Creates multiple departures in one transaction. The API derives each end time
from the selected bus line duration. Body: `{ "busLineId": 1, "dayOfWeeks": [1,2,3,4,5], "startTimes": ["06:00", "06:30", "07:00"], "driverId": 12 }`.
**PUT** `/admin/weekly-schedules/:scheduleId/shift`
- Replaces the driver assignment. Pass `{ "driverId": 12 }` or `{ "driverId": null }`.
The legacy `/admin/schedules` and `/schedule/daily` endpoints remain for older
clients, but new clients must use the weekly schedule endpoints above.
**GET** `/schedule/daily?date=2024-05-05`
- Retrieve daily schedule for driver
- Response:
```json
{
"date": "2024-05-05",
"routes": [
{
"id": 1,
"startTime": "2024-05-05T08:00:00Z",
"status": "scheduled",
"busLine": {
"lineNumber": 1,
"startStop": "Central Station",
"endStop": "Airport"
},
"busType": {
"name": "Standard Bus",
"seatCapacity": 50,
"licensePlate": "BUS-001"
}
}
]
}
```
**GET** `/routes/:routeId`
- Details of a specific route
- Response: `{ "route": {...} }`
### Public Schedule Overview
**GET** `/schedules/overview?date=2026-05-06&lineId=1`
- Fetches a date-based overview of scheduled services
- Weekly templates are selected by `day_of_week` and driver assignments by `shifts`
- One-off / incidental services are only included on their exact date
- Filters: `driverId`/`chauffeurId`, `stopId`/`busStopId`, `lineId`, `status`
- Response: `{ "date": "2026-05-06", "summary": {...}, "schedules": [...] }`
The legacy endpoint **GET** `/schedules` remains available for existing clients.
Example:
```bash
curl "https://transit.xtransit.testinstance.nl/schedules/overview?date=2026-05-06&chauffeurId=12&busStopId=3"
```
### Navigation
**POST** `/navigation/route`
- Save calculated route from OSRM
- Body:
```json
{
"routeId": 1,
"startLat": 52.5200,
"startLon": 13.4050,
"endLat": 52.5300,
"endLon": 13.4150,
"totalDistance": 15000,
"totalDuration": 900,
"geojson": {
"type": "LineString",
"coordinates": [[13.4050, 52.5200], ...]
}
}
```
**GET** `/bus-lines/:busLineId/stops`
- All stops of a bus line (for navigation waypoints)
- Response:
```json
{
"stops": [
{
"id": 1,
"order": 1,
"name": "Central Station",
"latitude": 52.52,
"longitude": 13.405
}
]
}
```
### GPS Tracking
**POST** `/gps/track`
- Save real-time position
- Body:
```json
{
"routeId": 1,
"latitude": 52.521,
"longitude": 13.406,
"speed": 45.5,
"heading": 180,
"accuracy": 5.0
}
```
**GET** `/gps/route/:routeId`
- All recorded position points of a route
- Response:
```json
{
"positions": [
{
"id": 1,
"latitude": 52.521,
"longitude": 13.406,
"speed": 45.5,
"heading": 180,
"recordedAt": "2024-05-05T08:15:00Z"
}
]
}
```
### Route Status
**PATCH** `/routes/:routeId/status`
- Update route status
- Body: `{ "status": "in_progress" }`
- Valid statuses: `scheduled`, `in_progress`, `completed`, `cancelled`
### Admin
All admin endpoints require, in addition to a valid JWT token, that your `userCode` is present in `ADMIN_USER_CODES`.
**POST** `/admin/stops`
- Create a reusable stop
- Body:
```json
{
"name": "Central Station",
"latitude": 52.52,
"longitude": 13.405
}
```
**POST** `/admin/bus-lines/:busLineId/stops`
- Link an existing stop to a bus line
- Body:
```json
{
"stopId": 1,
"stopOrder": 1,
"estimatedArrivalMinutes": 0
}
```
## Database Schema
### bus_types
- `id` - Primary key
- `name` - Bus type name (Standard Bus, etc.)
- `seat_capacity` - Number of seats
- `license_plate` - Unique license plate
### bus_lines
- `id` - Primary key
- `line_number` - Unique line number (1, 5, 12, etc.)
- `start_stop` - Start stop name
- `end_stop` - End stop name
- `estimated_duration_minutes` - Expected route duration
### stops
- `id` - Primary key
- `name` - Stop name
- `latitude`, `longitude` - Stop location
### route_stops
- `id` - Primary key
- `bus_line_id` - Which bus line the stop belongs to
- `stop_id` - Reusable stop reference
- `stop_order` - Stop sequence on that bus line
- `estimated_arrival_minutes` - Optional ETA per stop
### routes
- `id` - Primary key
- `user_id` - Driver (references workers.users)
- `bus_line_id` - Which line (references bus_lines)
- `bus_type_id` - Which bus (references bus_types)
- `start_time` - Scheduled start
- `status` - scheduled/in_progress/completed/cancelled
### navigation_routes
- `id` - Primary key
- `route_id` - Link to route (unique)
- `polyline_geojson` - GeoJSON LineString of the route
- `total_distance_meters` - Route distance
- `total_duration_seconds` - Estimated duration
### gps_tracking
- `id` - Primary key
- `route_id` - Which route
- `latitude`, `longitude` - Current position
- `speed_kmh` - Current speed
- `recorded_at` - Timestamp
## Integration with Flutter App
```dart
// In schedule_service.dart
// Save navigation route
await scheduleService.saveNavigationRoute(
routeId: 1,
navRoute: navigationRoute,
);
// Track GPS
await scheduleService.trackGPS(
routeId: 1,
currentPos: navigationPoint,
speed: 45.5,
heading: 180,
);
// Update route status
await scheduleService.updateRouteStatus(1, 'in_progress');
```
## Error Handling
All endpoints return error responses:
```json
{
"error": "error_code",
"details": "More information"
}
```
Common errors:
- `missing_token` - No Authorization header
- `invalid_token` - Token verification failed
- `missing_required_fields` - Required data missing
- `invalid_status` - Invalid status value
- `route_not_found` - Route not found
- `unauthorized` - User doesn't own this resource
+1
View File
@@ -0,0 +1 @@
window.__ENV__ = {};
+938
View File
@@ -0,0 +1,938 @@
import { useCallback, useEffect, useMemo, useState } from "react";
const runtime =
typeof window !== "undefined" && window.__ENV__ ? window.__ENV__ : {};
const configured = {
auth: runtime.AUTH_API || import.meta.env.VITE_AUTH_API || "",
employee: runtime.EMPLOYEE_API || import.meta.env.VITE_EMPLOYEE_API || "",
transit: runtime.TRANSIT_API || import.meta.env.VITE_TRANSIT_API || "",
errors: runtime.ERROR_LOG_API || import.meta.env.VITE_ERROR_LOG_API || "",
};
const serviceMeta = {
auth: {
label: "Auth API",
role: "Identity & tokens",
color: "amber",
data: "Login, JWT sessions, current user profile",
routes: ["/login", "/me", "/health"],
docs: "/docs/auth_api/README.md",
},
employee: {
label: "Employee API",
role: "People operations",
color: "teal",
data: "Employee onboarding and employee records",
routes: ["/health", "/createemployee"],
docs: "/docs/employee_api/README.md",
},
transit: {
label: "Transit API",
role: "Routes & schedules",
color: "blue",
data: "Schedules, routes, GPS, fleet and admin data",
routes: [
"/health",
"/public/schedules/:scheduleId/progress",
"/schedule/daily?date=YYYY-MM-DD",
"/admin/stops",
],
docs: "/docs/transit_api/README.md",
},
errors: {
label: "Error Log API",
role: "Incident archive",
color: "coral",
data: "Centralized bad requests and service errors",
routes: ["/health", "/error_log?limit=100"],
docs: "/docs/error_log_api/README.md",
},
};
const healthPaths = {
auth: "/health",
employee: "/health",
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: "/public/schedules/1/progress",
label: "Public schedule progress",
},
{
service: "transit",
method: "GET",
path: "/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 {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function App() {
const [urls, setUrls] = useState(
() =>
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) => {
const started = performance.now();
const url = urls[key];
if (!url) {
return {
ok: false,
status: 0,
body: { error: "No URL configured" },
latency: 0,
checkedAt: new Date().toISOString(),
};
}
const headers = token ? { Authorization: `Bearer ${token}` } : {};
try {
const response = await fetch(`${url}${healthPaths[key]}`, {
headers,
});
const body = await response.json().catch(() => null);
return {
ok: response.ok,
status: response.status,
body,
latency: Math.round(performance.now() - started),
checkedAt: new Date().toISOString(),
};
} catch (error) {
return {
ok: false,
status: 0,
body: { error: error.message },
latency: Math.round(performance.now() - started),
checkedAt: new Date().toISOString(),
};
}
},
[token, urls],
);
const refreshHealth = useCallback(async () => {
const results = await Promise.all(
Object.keys(serviceMeta).map(async (key) => [
key,
await checkService(key),
]),
);
const next = Object.fromEntries(results);
setHealth(next);
setHistory((current) =>
Object.fromEntries(
Object.entries(next).map(([key, value]) => [
key,
[...(current[key] || []), value.latency].slice(-18),
]),
),
);
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) => {
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;
const configuredCount = Object.keys(serviceMeta).length;
const avgLatency = Math.round(
Object.values(health).reduce((sum, item) => sum + (item.latency || 0), 0) /
Math.max(Object.keys(health).length, 1),
);
return (
<div className="app-shell">
<header className="topbar">
<div className="brand">
<span className="brand-mark">X</span>
<div>
<strong>TRANSIT CONTROL ROOM</strong>
<span>service observability</span>
</div>
</div>
<div className="top-actions">
<span className="live-dot" /> Live polling{" "}
<select
value={interval}
onChange={(event) => setIntervalValue(Number(event.target.value))}
>
<option value="15">15 sec</option>
<option value="30">30 sec</option>
<option value="60">1 min</option>
</select>
<button className="ghost-button" onClick={refreshHealth}>
Refresh now
</button>
</div>
</header>
<main>
<section className="hero-row">
<div>
<p className="eyebrow">
SYSTEM PULSE /{" "}
{lastRefresh ? lastRefresh.toLocaleTimeString() : "CONNECTING"}
</p>
<h1>Know what is moving.</h1>
<p className="hero-copy">
A live field view across the APIs that keep XTransit running.
</p>
</div>
<div className="pulse-stat">
<span>Healthy services</span>
<strong>
{healthyCount}
<small>/{configuredCount}</small>
</strong>
<em>{avgLatency} ms avg response</em>
</div>
</section>
<nav className="tabs">
<button
className={activeView === "overview" ? "active" : ""}
onClick={() => setActiveView("overview")}
>
Overview
</button>
<button
className={activeView === "catalog" ? "active" : ""}
onClick={() => setActiveView("catalog")}
>
Service catalog
</button>
<button
className={activeView === "docs" ? "active" : ""}
onClick={() => setActiveView("docs")}
>
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")}
>
Connections
</button>
</nav>
{activeView === "overview" && (
<>
<section className="service-grid">
{Object.entries(serviceMeta).map(([key, meta]) => (
<ServiceCard
key={key}
serviceKey={key}
meta={meta}
health={health[key]}
history={history[key] || []}
/>
))}
</section>
<section className="lower-grid">
<div className="panel">
<div className="panel-heading">
<div>
<p className="eyebrow">DATA MAP</p>
<h2>What each service serves</h2>
</div>
<span className="panel-count">
{configuredCount} surfaces
</span>
</div>
<div className="data-list">
{Object.entries(serviceMeta).map(([key, meta]) => (
<div className="data-row" key={key}>
<span className={`service-dot ${meta.color}`} />
<strong>{meta.label}</strong>
<span>{meta.data}</span>
</div>
))}
</div>
</div>
<QuickNotes />
</section>
</>
)}
{activeView === "catalog" && <Catalog />}
{activeView === "docs" && <Docs />}
{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}
/>
)}
</main>
</div>
);
}
function ServiceCard({ serviceKey, meta, health, history }) {
const status = !health
? "checking"
: health.ok
? "healthy"
: health.status === 0
? "offline"
: "degraded";
return (
<article className={`service-card ${meta.color}`}>
<div className="card-top">
<div>
<span className={`service-icon ${meta.color}`}>
{meta.label.slice(0, 2).toUpperCase()}
</span>
<h3>{meta.label}</h3>
<p>{meta.role}</p>
</div>
<span className={`status-pill ${status}`}>
<i />
{status}
</span>
</div>
<div className="metric-line">
<span>Response time</span>
<strong>{health ? `${health.latency} ms` : "--"}</strong>
</div>
<div className="sparkline">
{history.length > 1 ? (
history.map((value, index) => (
<span
key={`${value}-${index}`}
style={{ height: `${Math.max(10, Math.min(100, value / 4))}%` }}
/>
))
) : (
<em>Waiting for first reading</em>
)}
</div>
{health?.body && (
<details>
<summary>Health payload</summary>
<pre>{pretty(health.body)}</pre>
</details>
)}
</article>
);
}
function QuickNotes() {
return (
<div className="panel suggestion-panel">
<p className="eyebrow">OPERATOR NOTES</p>
<h2>Useful next signals</h2>
<ul>
<li>
<strong>Database reachability</strong>
<span>
Transit health already reports DB status. Employee health currently
only proves the process is reachable.
</span>
</li>
<li>
<strong>Auth-aware checks</strong>
<span>
Login with admin credentials to unlock /me and error history.
</span>
</li>
<li>
<strong>Safer testing</strong>
<span>
The request lab is intentionally allowlisted, with confirmation
before writes.
</span>
</li>
</ul>
</div>
);
}
function Docs() {
return (
<section className="panel docs-panel">
<div className="panel-heading">
<div>
<p className="eyebrow">SERVICE DOCUMENTATION</p>
<h2>API Documentation & References</h2>
</div>
<span className="panel-count">
{Object.keys(serviceMeta).length} services
</span>
</div>
<div className="docs-grid">
{Object.entries(serviceMeta).map(([key, meta]) => (
<div className={`doc-card ${meta.color}`} key={key}>
<div className="doc-header">
<span className={`service-dot ${meta.color}`} />
<div>
<strong>{meta.label}</strong>
<p>{meta.role}</p>
</div>
</div>
<p className="doc-description">{meta.data}</p>
<div className="doc-links">
{meta.docs && (
<a
href={meta.docs}
target="_blank"
rel="noopener noreferrer"
className="doc-link"
>
View Documentation →
</a>
)}
<details className="routes-detail">
<summary>API Routes</summary>
<ul>
{meta.routes.map((route) => (
<li key={route}>
<code>{route}</code>
</li>
))}
</ul>
</details>
</div>
</div>
))}
</div>
</section>
);
}
function Catalog() {
return (
<section className="panel catalog-panel">
<div className="panel-heading">
<div>
<p className="eyebrow">ENDPOINT ATLAS</p>
<h2>Known API surfaces</h2>
</div>
<span className="panel-count">read-only reference</span>
</div>
{Object.entries(serviceMeta).map(([key, meta]) => (
<div className="catalog-block" key={key}>
<div>
<span className={`service-dot ${meta.color}`} />
<strong>{meta.label}</strong>
<p>{meta.data}</p>
</div>
<div className="route-chips">
{meta.routes.map((route) => (
<code key={route}>{route}</code>
))}
</div>
</div>
))}
</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 }) {
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">
<div className="panel-heading">
<div>
<p className="eyebrow">RUNTIME CONFIGURATION</p>
<h2>Connections</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="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)}
>
Save connections
</button>
</section>
);
}
export default App;
+10
View File
@@ -0,0 +1,10 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./styles.css";
createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+782
View File
@@ -0,0 +1,782 @@
@import url("https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap");
:root {
font-family: Manrope, sans-serif;
color: #e7eeee;
background: #101517;
font-synthesis: none;
--bg: #101517;
--panel: #172022;
--line: #2b393a;
--muted: #91a2a1;
--mint: #8ae8c2;
--amber: #f2bd70;
--blue: #7eb7f5;
--coral: #f58d7d;
--violet: #c1a5fa;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
background:
radial-gradient(circle at 82% -10%, #24403b 0, transparent 32%),
linear-gradient(135deg, #101517 0%, #111b1d 54%, #182020 100%);
min-height: 100vh;
}
.app-shell {
max-width: 1440px;
margin: auto;
padding: 0 42px 70px;
}
.topbar {
height: 82px;
border-bottom: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: space-between;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border: 1px solid var(--mint);
color: var(--mint);
font-weight: 800;
font-size: 20px;
transform: rotate(45deg);
}
.brand-mark::first-letter {
transform: rotate(-45deg);
}
.brand strong,
.brand span {
display: block;
}
.brand strong {
font:
600 12px "DM Mono",
monospace;
letter-spacing: 0.12em;
}
.brand div span {
color: var(--muted);
font-size: 11px;
margin-top: 4px;
}
.top-actions {
display: flex;
align-items: center;
gap: 14px;
color: var(--muted);
font:
500 12px "DM Mono",
monospace;
}
.live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--mint);
box-shadow: 0 0 14px var(--mint);
}
select,
input,
textarea {
font: inherit;
color: inherit;
background: #111a1c;
border: 1px solid var(--line);
border-radius: 4px;
padding: 10px 12px;
outline: none;
}
select:focus,
input:focus,
textarea:focus {
border-color: var(--mint);
}
.top-actions select {
padding: 7px 9px;
font-size: 11px;
}
.ghost-button {
border: 1px solid var(--line);
background: transparent;
color: #d3dddd;
padding: 9px 12px;
border-radius: 4px;
cursor: pointer;
}
.ghost-button:hover {
border-color: var(--mint);
color: var(--mint);
}
.hero-row {
display: flex;
justify-content: space-between;
align-items: end;
padding: 68px 0 38px;
}
.eyebrow {
font:
500 10px "DM Mono",
monospace;
color: var(--mint);
letter-spacing: 0.16em;
margin: 0 0 13px;
}
.hero-row h1 {
font-size: clamp(38px, 6vw, 76px);
line-height: 0.98;
letter-spacing: -0.04em;
margin: 0;
max-width: 680px;
}
.hero-copy {
color: var(--muted);
font-size: 16px;
margin: 20px 0 0;
}
.pulse-stat {
border-left: 1px solid var(--line);
padding-left: 25px;
min-width: 180px;
}
.pulse-stat span,
.pulse-stat em {
display: block;
color: var(--muted);
font:
11px "DM Mono",
monospace;
}
.pulse-stat strong {
display: block;
font-size: 52px;
line-height: 1.1;
margin: 7px 0;
color: var(--mint);
}
.pulse-stat small {
font-size: 20px;
color: var(--muted);
font-weight: 500;
}
.pulse-stat em {
font-style: normal;
}
.tabs {
display: flex;
gap: 24px;
border-bottom: 1px solid var(--line);
margin-bottom: 28px;
overflow: auto;
}
.tabs button {
white-space: nowrap;
background: none;
border: 0;
border-bottom: 2px solid transparent;
color: var(--muted);
padding: 13px 0;
cursor: pointer;
font-weight: 700;
font-size: 12px;
}
.tabs button.active,
.tabs button:hover {
color: #fff;
border-color: var(--mint);
}
.tabs b {
color: var(--coral);
margin-left: 5px;
}
.service-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
.service-card,
.panel {
background: rgba(23, 32, 34, 0.88);
border: 1px solid var(--line);
border-radius: 6px;
}
.service-card {
padding: 19px;
min-height: 250px;
border-top: 2px solid var(--card-color);
}
.service-card.amber {
--card-color: var(--amber);
}
.service-card.teal {
--card-color: var(--mint);
}
.service-card.blue {
--card-color: var(--blue);
}
.service-card.coral {
--card-color: var(--coral);
}
.service-card.violet {
--card-color: var(--violet);
}
.card-top {
display: flex;
justify-content: space-between;
gap: 8px;
}
.service-icon {
display: inline-grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 3px;
font:
500 10px "DM Mono",
monospace;
background: #293332;
color: var(--card-color);
}
.card-top h3 {
font-size: 14px;
margin: 13px 0 3px;
}
.card-top p {
color: var(--muted);
font-size: 11px;
margin: 0;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 6px;
height: 22px;
border-radius: 20px;
padding: 0 8px;
font:
500 10px "DM Mono",
monospace;
text-transform: uppercase;
}
.status-pill i {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.status-pill.healthy {
color: var(--mint);
background: #17372f;
}
.status-pill.degraded {
color: var(--coral);
background: #3b2523;
}
.status-pill.offline {
color: #f17c76;
background: #3b2523;
}
.status-pill.checking {
color: var(--amber);
background: #3c3020;
}
.metric-line {
border-top: 1px solid var(--line);
margin-top: 25px;
padding-top: 13px;
display: flex;
justify-content: space-between;
color: var(--muted);
font:
10px "DM Mono",
monospace;
}
.metric-line strong {
color: #e8eeee;
font-weight: 500;
}
.sparkline {
height: 45px;
display: flex;
align-items: end;
gap: 3px;
margin-top: 12px;
}
.sparkline span {
display: block;
flex: 1;
min-width: 3px;
background: var(--card-color);
opacity: 0.75;
border-radius: 1px;
}
.sparkline em {
font:
10px "DM Mono",
monospace;
color: #607170;
}
.service-card details {
margin-top: 12px;
color: var(--muted);
font-size: 10px;
}
.service-card summary {
cursor: pointer;
}
.service-card pre {
max-height: 100px;
overflow: auto;
color: #c6d5d3;
font:
10px "DM Mono",
monospace;
}
.lower-grid {
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: 12px;
margin-top: 12px;
}
.panel {
padding: 25px;
}
.panel-heading {
display: flex;
justify-content: space-between;
align-items: start;
gap: 20px;
margin-bottom: 21px;
}
.panel h2 {
font-size: 19px;
margin: 0;
letter-spacing: -0.02em;
}
.panel-count {
font:
10px "DM Mono",
monospace;
color: var(--muted);
border: 1px solid var(--line);
padding: 6px 8px;
border-radius: 3px;
white-space: nowrap;
}
.data-list {
border-top: 1px solid var(--line);
}
.data-row {
display: grid;
grid-template-columns: 10px 150px 1fr;
gap: 11px;
align-items: center;
padding: 14px 0;
border-bottom: 1px solid var(--line);
font-size: 12px;
}
.data-row > span:last-child {
color: var(--muted);
font-size: 11px;
}
.service-dot {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
}
.service-dot.amber {
background: var(--amber);
}
.service-dot.teal {
background: var(--mint);
}
.service-dot.blue {
background: var(--blue);
}
.service-dot.coral {
background: var(--coral);
}
.service-dot.violet {
background: var(--violet);
}
.suggestion-panel ul {
list-style: none;
margin: 0;
padding: 0;
}
.suggestion-panel li {
padding: 13px 0;
border-top: 1px solid var(--line);
}
.suggestion-panel li strong,
.suggestion-panel li span {
display: block;
}
.suggestion-panel li strong {
font-size: 12px;
}
.suggestion-panel li span {
color: var(--muted);
font-size: 11px;
line-height: 1.5;
margin-top: 5px;
}
.catalog-panel,
.incidents-panel,
.settings-panel {
min-height: 500px;
}
.catalog-block {
display: grid;
grid-template-columns: 270px 1fr;
gap: 20px;
padding: 20px 0;
border-top: 1px solid var(--line);
}
.catalog-block > div:first-child {
display: flex;
gap: 9px;
align-items: start;
}
.catalog-block p {
color: var(--muted);
font-size: 11px;
margin: 7px 0 0 16px;
}
.route-chips {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.route-chips code {
font:
11px "DM Mono",
monospace;
color: #c8d8d5;
border: 1px solid var(--line);
background: #111a1c;
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 {
display: grid;
place-items: center;
text-align: center;
min-height: 250px;
color: var(--muted);
font-size: 12px;
gap: 8px;
}
.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;
gap: 0 18px;
}
.save-button {
max-width: 180px;
}
.incidents-panel .panel-heading {
align-items: center;
}
@media (max-width: 1100px) {
.service-grid {
grid-template-columns: repeat(3, 1fr);
}
.service-card:last-child {
grid-column: auto;
}
.hero-row {
align-items: start;
gap: 30px;
}
.lower-grid {
grid-template-columns: 1fr;
}
.pulse-stat {
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;
}
.docs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 16px;
}
.doc-card {
background: rgba(23, 32, 34, 0.88);
border: 1px solid var(--line);
border-radius: 6px;
padding: 20px;
border-top: 2px solid var(--card-color);
}
.doc-card.amber { --card-color: var(--amber); }
.doc-card.teal { --card-color: var(--mint); }
.doc-card.blue { --card-color: var(--blue); }
.doc-card.coral { --card-color: var(--coral); }
.doc-header {
display: flex;
gap: 12px;
align-items: start;
margin-bottom: 12px;
}
.doc-header .service-dot {
flex-shrink: 0;
margin-top: 2px;
}
.doc-header strong { display: block; font-size: 14px; }
.doc-header p { margin: 3px 0 0; color: var(--muted); font-size: 11px; }
.doc-description {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
margin-bottom: 14px;
}
.doc-links {
display: flex;
flex-direction: column;
gap: 12px;
}
.doc-link {
color: var(--mint);
text-decoration: none;
font: 500 11px "DM Mono", monospace;
display: inline-flex;
align-items: center;
gap: 6px;
}
.doc-link:hover { text-decoration: underline; }
.routes-detail summary {
color: var(--muted);
font: 500 10px "DM Mono", monospace;
cursor: pointer;
text-transform: uppercase;
}
.routes-detail ul {
margin: 8px 0 0;
padding-left: 18px;
font-size: 11px;
color: #c6d5d3;
}
.routes-detail li { margin: 4px 0; }
.routes-detail code {
font: 11px "DM Mono", monospace;
background: #111a1c;
padding: 2px 4px;
border-radius: 2px;
}
@media (max-width: 700px) {
.app-shell {
padding: 0 18px 40px;
}
.topbar {
height: auto;
padding: 18px 0;
align-items: start;
gap: 14px;
flex-direction: column;
}
.top-actions {
flex-wrap: wrap;
}
.hero-row {
padding: 43px 0 28px;
display: block;
}
.pulse-stat {
border-left: 0;
border-top: 1px solid var(--line);
padding: 15px 0 0;
margin-top: 28px;
}
.pulse-stat strong {
font-size: 42px;
}
.service-grid {
grid-template-columns: 1fr 1fr;
}
.service-card {
min-height: 230px;
padding: 14px;
}
.card-top {
display: block;
}
.status-pill {
margin-top: 12px;
}
.catalog-block,
.lab-grid {
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;
}
.tabs {
gap: 18px;
}
.panel {
padding: 18px;
}
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
});
+19 -3
View File
@@ -1,10 +1,10 @@
#!/bin/bash #!/bin/bash
# Build and push Docker images to registry # Build and push Docker images to registry
# Usage: ./scripts/build-and-push.sh [registry-url] # Usage: ./scripts/build-and-push.sh [registry-url] [version]
set -e set -e
REGISTRY=${1:-ghcr.io/sven491/xtransit} REGISTRY=${1:-gitea.testinstance.nl/sven/the-transit-project}
VERSION=${2:-latest} VERSION=${2:-latest}
echo "🔨 Building Docker images..." echo "🔨 Building Docker images..."
@@ -21,10 +21,18 @@ docker build -t ${REGISTRY}/transit_api:${VERSION} ./transit_api
echo " Building employee_api..." echo " Building employee_api..."
docker build -t ${REGISTRY}/employee_api:${VERSION} ./employee_api docker build -t ${REGISTRY}/employee_api:${VERSION} ./employee_api
# Build error_log_api
echo " Building error_log_api..."
docker build -t ${REGISTRY}/error_log_api:${VERSION} ./error_log_api
# Build admin_ui # Build admin_ui
echo " Building admin_ui..." echo " Building admin_ui..."
docker build -t ${REGISTRY}/admin_ui:${VERSION} ./admin_ui docker build -t ${REGISTRY}/admin_ui:${VERSION} ./admin_ui
# Build monitoring_ui
echo " Building monitoring_ui..."
docker build -t ${REGISTRY}/monitoring_ui:${VERSION} ./monitoring_ui
echo "✅ Build complete!" echo "✅ Build complete!"
echo "" echo ""
echo "🚀 Pushing images to registry: $REGISTRY" echo "🚀 Pushing images to registry: $REGISTRY"
@@ -38,14 +46,22 @@ echo " ✓ Pushed transit_api:${VERSION}"
docker push ${REGISTRY}/employee_api:${VERSION} docker push ${REGISTRY}/employee_api:${VERSION}
echo " ✓ Pushed employee_api:${VERSION}" echo " ✓ Pushed employee_api:${VERSION}"
docker push ${REGISTRY}/error_log_api:${VERSION}
echo " ✓ Pushed error_log_api:${VERSION}"
docker push ${REGISTRY}/admin_ui:${VERSION} docker push ${REGISTRY}/admin_ui:${VERSION}
echo " ✓ Pushed admin_ui:${VERSION}" echo " ✓ Pushed admin_ui:${VERSION}"
docker push ${REGISTRY}/monitoring_ui:${VERSION}
echo " ✓ Pushed monitoring_ui:${VERSION}"
echo "" echo ""
echo "✅ All images pushed successfully!" echo "✅ All images pushed successfully!"
echo "" echo ""
echo "Update docker-compose.yml with:" echo "docker-compose service images:"
echo " image: ${REGISTRY}/auth_api:${VERSION}" echo " image: ${REGISTRY}/auth_api:${VERSION}"
echo " image: ${REGISTRY}/transit_api:${VERSION}" echo " image: ${REGISTRY}/transit_api:${VERSION}"
echo " image: ${REGISTRY}/employee_api:${VERSION}" echo " image: ${REGISTRY}/employee_api:${VERSION}"
echo " image: ${REGISTRY}/error_log_api:${VERSION}"
echo " image: ${REGISTRY}/admin_ui:${VERSION}" echo " image: ${REGISTRY}/admin_ui:${VERSION}"
echo " image: ${REGISTRY}/monitoring_ui:${VERSION}"
+6 -4
View File
@@ -13,10 +13,12 @@ case $COMMAND in
echo "✅ Services started!" echo "✅ Services started!"
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 " Monitoring UI: http://localhost:5175"
echo " Transit API: http://localhost:5001" echo " Auth API: https://auth.xtransit.testinstance.nl"
echo " Employee API: http://localhost:4000" echo " Transit API: https://transit.xtransit.testinstance.nl"
echo " Employee API: http://localhost:4000"
echo " Error Log API: https://error.xtransit.testinstance.nl"
;; ;;
stop) stop)
+3144 -1680
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.