Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89e4f90fc3 | |||
| 39b0b07a77 | |||
| 48c710bfbf | |||
| 1c055d84e8 | |||
| d7100e6f2a | |||
| 6e0b7e4793 | |||
| da5c6b2839 | |||
| 38782bca6d | |||
| a17125bdee | |||
| 217dfd3abc | |||
| 635a130f0b | |||
| f935bbeb8c | |||
| 1154d1d658 | |||
| 3f0a02aa04 |
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
@@ -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/
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -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`
|
|
||||||
@@ -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.
|
|
||||||
@@ -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).
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
version: '3.8'
|
version: "3.8"
|
||||||
services:
|
services:
|
||||||
admin-ui:
|
admin-ui:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "5174:80"
|
- "5174:80"
|
||||||
environment:
|
environment:
|
||||||
VITE_AUTH_API: ${VITE_AUTH_API:-http://localhost:5000}
|
VITE_AUTH_API: ${VITE_AUTH_API:-https://auth.xtransit.testinstance.nl}
|
||||||
VITE_TRANSIT_API: ${VITE_TRANSIT_API:-http://localhost:5001}
|
VITE_TRANSIT_API: ${VITE_TRANSIT_API:-https://transit.xtransit.testinstance.nl}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ set -e
|
|||||||
|
|
||||||
cat > /usr/share/nginx/html/env-config.js <<EOF
|
cat > /usr/share/nginx/html/env-config.js <<EOF
|
||||||
window.__ENV__ = {
|
window.__ENV__ = {
|
||||||
AUTH_API: "${AUTH_API:-http://localhost:5000}",
|
AUTH_API: "${AUTH_API:-https://auth.xtransit.testinstance.nl}",
|
||||||
TRANSIT_API: "${TRANSIT_API:-http://localhost:5001}",
|
TRANSIT_API: "${TRANSIT_API:-https://transit.xtransit.testinstance.nl}",
|
||||||
ERROR_LOG_API: "${ERROR_LOG_API:-http://localhost:5003}"
|
ERROR_LOG_API: "${ERROR_LOG_API:-https://error.xtransit.testinstance.nl}"
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,87 +1,106 @@
|
|||||||
import axios from 'axios'
|
import axios from "axios";
|
||||||
|
|
||||||
const RUNTIME = typeof window !== 'undefined' && window.__ENV__ ? window.__ENV__ : {}
|
const RUNTIME =
|
||||||
|
typeof window !== "undefined" && window.__ENV__ ? window.__ENV__ : {};
|
||||||
|
|
||||||
const normalizeApiBaseUrl = (value, fallbackPath) => {
|
const normalizeApiBaseUrl = (value, fallbackPath) => {
|
||||||
const rawValue = value || ''
|
const rawValue = value || "";
|
||||||
if (!rawValue) return fallbackPath
|
if (!rawValue) return fallbackPath;
|
||||||
if (/^https?:\/\//i.test(rawValue) || rawValue.startsWith('//') || rawValue.startsWith('/')) {
|
if (
|
||||||
return rawValue
|
/^https?:\/\//i.test(rawValue) ||
|
||||||
|
rawValue.startsWith("//") ||
|
||||||
|
rawValue.startsWith("/")
|
||||||
|
) {
|
||||||
|
return rawValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${window.location.protocol}//${rawValue}`
|
return `${window.location.protocol}//${rawValue}`;
|
||||||
}
|
};
|
||||||
|
|
||||||
const AUTH_API = normalizeApiBaseUrl(RUNTIME.AUTH_API || import.meta.env.VITE_AUTH_API, 'http://localhost:5000')
|
const AUTH_API = normalizeApiBaseUrl(
|
||||||
const TRANSIT_API = normalizeApiBaseUrl(RUNTIME.TRANSIT_API || import.meta.env.VITE_TRANSIT_API, 'http://localhost:5001')
|
RUNTIME.AUTH_API || import.meta.env.VITE_AUTH_API,
|
||||||
const ERROR_LOG_API = normalizeApiBaseUrl(RUNTIME.ERROR_LOG_API || import.meta.env.VITE_ERROR_LOG_API, 'http://localhost:5003')
|
"https://auth.xtransit.testinstance.nl",
|
||||||
|
);
|
||||||
|
const TRANSIT_API = normalizeApiBaseUrl(
|
||||||
|
RUNTIME.TRANSIT_API || import.meta.env.VITE_TRANSIT_API,
|
||||||
|
"https://transit.xtransit.testinstance.nl",
|
||||||
|
);
|
||||||
|
const ERROR_LOG_API = normalizeApiBaseUrl(
|
||||||
|
RUNTIME.ERROR_LOG_API || import.meta.env.VITE_ERROR_LOG_API,
|
||||||
|
"https://error.xtransit.testinstance.nl",
|
||||||
|
);
|
||||||
|
|
||||||
const reportClientError = async ({ service, partOfService, error }) => {
|
const reportClientError = async ({ service, partofservice, error }) => {
|
||||||
try {
|
try {
|
||||||
await fetch(`${ERROR_LOG_API}/error_log`, {
|
await fetch(`${ERROR_LOG_API}/error_log`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
service,
|
service,
|
||||||
partOfService,
|
partofservice,
|
||||||
error: typeof error === 'string' ? error : JSON.stringify(error),
|
error: typeof error === "string" ? error : JSON.stringify(error),
|
||||||
}),
|
}),
|
||||||
})
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore logging failures
|
// ignore logging failures
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export const authClient = axios.create({ baseURL: AUTH_API })
|
export const authClient = axios.create({ baseURL: AUTH_API });
|
||||||
|
|
||||||
authClient.interceptors.response.use(
|
authClient.interceptors.response.use(
|
||||||
response => response,
|
(response) => response,
|
||||||
error => {
|
(error) => {
|
||||||
void reportClientError({
|
void reportClientError({
|
||||||
service: 'admin_ui',
|
service: "admin_ui",
|
||||||
partOfService: 'authClient',
|
partofservice: "authClient",
|
||||||
error: error?.response?.data?.error || error?.message || 'authClient_error',
|
error:
|
||||||
})
|
error?.response?.data?.error || error?.message || "authClient_error",
|
||||||
return Promise.reject(error)
|
});
|
||||||
}
|
return Promise.reject(error);
|
||||||
)
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const transitClient = (token) => {
|
export const transitClient = (token) => {
|
||||||
const client = axios.create({
|
const client = axios.create({
|
||||||
baseURL: TRANSIT_API,
|
baseURL: TRANSIT_API,
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
});
|
||||||
|
|
||||||
// Auto-logout on unauthorized responses to avoid delayed failures
|
// Auto-logout on unauthorized responses to avoid delayed failures
|
||||||
client.interceptors.response.use(
|
client.interceptors.response.use(
|
||||||
response => response,
|
(response) => response,
|
||||||
error => {
|
(error) => {
|
||||||
try {
|
try {
|
||||||
const status = error?.response?.status
|
const status = error?.response?.status;
|
||||||
if (status === 401 || status === 403) {
|
if (status === 401 || status === 403) {
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem("token");
|
||||||
localStorage.removeItem('user')
|
localStorage.removeItem("user");
|
||||||
// reload to let App show the Login screen
|
// reload to let App show the Login screen
|
||||||
if (typeof window !== 'undefined') window.location.reload()
|
if (typeof window !== "undefined") window.location.reload();
|
||||||
}
|
}
|
||||||
void reportClientError({
|
void reportClientError({
|
||||||
service: 'admin_ui',
|
service: "admin_ui",
|
||||||
partOfService: 'transitClient',
|
partofservice: "transitClient",
|
||||||
error: error?.response?.data?.error || error?.message || 'transitClient_error',
|
error:
|
||||||
})
|
error?.response?.data?.error ||
|
||||||
|
error?.message ||
|
||||||
|
"transitClient_error",
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error);
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
return client
|
return client;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const errorLogClient = (token) => axios.create({
|
export const errorLogClient = (token) =>
|
||||||
baseURL: ERROR_LOG_API,
|
axios.create({
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
baseURL: ERROR_LOG_API,
|
||||||
})
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
|
||||||
export { reportClientError }
|
export { reportClientError };
|
||||||
|
|||||||
@@ -1,42 +1,44 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react'
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { errorLogClient } from '../api'
|
import { errorLogClient } from "../api";
|
||||||
|
|
||||||
const formatDate = (value) => {
|
const formatDate = (value) => {
|
||||||
if (!value) return '—'
|
if (!value) return "—";
|
||||||
try {
|
try {
|
||||||
return new Date(value).toLocaleString()
|
return new Date(value).toLocaleString();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return String(value)
|
return String(value);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function ErrorLogViewer({ token }) {
|
export default function ErrorLogViewer({ token }) {
|
||||||
const client = useMemo(() => errorLogClient(token), [token])
|
const client = useMemo(() => errorLogClient(token), [token]);
|
||||||
const [logs, setLogs] = useState([])
|
const [logs, setLogs] = useState([]);
|
||||||
const [service, setService] = useState('')
|
const [service, setService] = useState("");
|
||||||
const [partOfService, setPartOfService] = useState('')
|
const [partofservice, setPartofservice] = useState("");
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false);
|
||||||
const [message, setMessage] = useState(null)
|
const [message, setMessage] = useState(null);
|
||||||
|
|
||||||
const loadLogs = async () => {
|
const loadLogs = async () => {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
setMessage(null)
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
const params = { limit: 100 }
|
const params = { limit: 100 };
|
||||||
if (service) params.service = service
|
if (service) params.service = service;
|
||||||
if (partOfService) params.partOfService = partOfService
|
if (partofservice) params.partofservice = partofservice;
|
||||||
const res = await client.get('/error_log', { params })
|
const res = await client.get("/error_log", { params });
|
||||||
setLogs(res.data.errors || [])
|
setLogs(res.data.errors || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessage(err.response?.data?.error || err.message || 'Kon fouten niet laden')
|
setMessage(
|
||||||
|
err.response?.data?.error || err.message || "Kon fouten niet laden",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadLogs()
|
loadLogs();
|
||||||
}, [service, partOfService])
|
}, [service, partofservice]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
@@ -46,29 +48,41 @@ export default function ErrorLogViewer({ token }) {
|
|||||||
<p>Laatste foutmeldingen uit de centrale logging service.</p>
|
<p>Laatste foutmeldingen uit de centrale logging service.</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="secondary" onClick={loadLogs} disabled={loading}>
|
<button className="secondary" onClick={loadLogs} disabled={loading}>
|
||||||
{loading ? 'Laden...' : 'Ververs'}
|
{loading ? "Laden..." : "Ververs"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<input placeholder="Service filter" value={service} onChange={e => setService(e.target.value)} />
|
<input
|
||||||
<input placeholder="Part of service filter" value={partOfService} onChange={e => setPartOfService(e.target.value)} />
|
placeholder="Service filter"
|
||||||
|
value={service}
|
||||||
|
onChange={(e) => setService(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="Part of service filter"
|
||||||
|
value={partofservice}
|
||||||
|
onChange={(e) => setPartofservice(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{message && <div className="message">{message}</div>}
|
{message && <div className="message">{message}</div>}
|
||||||
|
|
||||||
<div className="list-grid">
|
<div className="list-grid">
|
||||||
{logs.map(log => (
|
{logs.map((log) => (
|
||||||
<article className="mini-card" key={log.id}>
|
<article className="mini-card" key={log.id}>
|
||||||
<strong>{log.service}</strong>
|
<strong>{log.service}</strong>
|
||||||
<div>{formatDate(log.date)}</div>
|
<div>{formatDate(log.date)}</div>
|
||||||
<div className="muted" style={{ marginTop: 6 }}>{log.partOfService || '—'}</div>
|
<div className="muted" style={{ marginTop: 6 }}>
|
||||||
|
{log.partofservice || "—"}
|
||||||
|
</div>
|
||||||
<div style={{ marginTop: 8 }}>{log.error}</div>
|
<div style={{ marginTop: 8 }}>{log.error}</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!logs.length && !message && <div className="muted">Nog geen foutmeldingen gevonden.</div>}
|
{!logs.length && !message && (
|
||||||
|
<div className="muted">Nog geen foutmeldingen gevonden.</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
@@ -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/
|
||||||
@@ -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;"]
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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 "$@"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1478
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
window.__ENV__ = {};
|
||||||
@@ -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;
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
});
|
||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user