Added docviewer for docks on the monitoring UI
Build and Push Docker Images / detect-changes (push) Successful in 29s
Build and Push Docker Images / build-auth-api (push) Successful in 2m1s
Build and Push Docker Images / build-employee-api (push) Successful in 2m1s
Build and Push Docker Images / build-transit-api (push) Successful in 2m9s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m57s
Build and Push Docker Images / build-admin-ui (push) Successful in 2m50s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 2m39s
Build and Push Docker Images / deploy-test-node (push) Successful in 6s

This commit is contained in:
Sven laptop
2026-09-24 20:24:53 +02:00
parent e169e37b5e
commit 46fb9b9d6b
3 changed files with 338 additions and 95 deletions
+114 -43
View File
@@ -1,4 +1,25 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { marked } from "marked";
import { docGroups, docsIndex, resolveDocRel } from "./docs";
marked.use({
renderer: {
heading({ tokens, depth }) {
const text = this.parser.parseInline(tokens);
const id = slug(text) || "section";
return `<h${depth} id="${id}">${text}</h${depth}>`;
},
},
});
function slug(text) {
return String(text || "")
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-");
}
const runtime = const runtime =
typeof window !== "undefined" && window.__ENV__ ? window.__ENV__ : {}; typeof window !== "undefined" && window.__ENV__ ? window.__ENV__ : {};
@@ -451,7 +472,7 @@ function App() {
</> </>
)} )}
{activeView === "catalog" && <Catalog />} {activeView === "catalog" && <Catalog />}
{activeView === "docs" && <Docs />} {activeView === "docs" && <DocsViewer />}
{activeView === "incidents" && ( {activeView === "incidents" && (
<Incidents errors={errors} state={errorState} reload={loadErrors} /> <Incidents errors={errors} state={errorState} reload={loadErrors} />
)} )}
@@ -560,53 +581,103 @@ function QuickNotes() {
</div> </div>
); );
} }
function Docs() { function DocsViewer() {
const [selected, setSelected] = useState(
() => docsIndex.find((d) => d.group === "(top level)" && d.file === "README.md")?.rel || docsIndex[0]?.rel || "",
);
const [query, setQuery] = useState("");
const contentRef = useRef(null);
const doc = docsIndex.find((d) => d.rel === selected) || docsIndex[0];
const html = useMemo(() => (doc ? marked.parse(doc.content) : ""), [doc]);
const needle = query.trim().toLowerCase();
const visible = needle
? docsIndex.filter(
(d) =>
d.label.toLowerCase().includes(needle) ||
d.rel.toLowerCase().includes(needle),
)
: docsIndex;
const groups = docGroups.filter((g) => visible.some((d) => d.group === g));
useEffect(() => {
const el = contentRef.current;
if (!el) return;
const onClick = (event) => {
const anchor = event.target.closest("a");
if (!anchor) return;
const href = anchor.getAttribute("href");
if (!href) return;
if (href.startsWith("#")) {
event.preventDefault();
const target = el.querySelector(href);
if (target) target.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
if (/^https?:/i.test(href)) return;
event.preventDefault();
const resolved = resolveDocRel(doc.rel, href);
if (resolved) setSelected(resolved);
else window.open(new URL(href, window.location.href).href, "_blank", "noopener,noreferrer");
};
el.addEventListener("click", onClick);
return () => el.removeEventListener("click", onClick);
}, [doc.rel]);
return ( return (
<section className="panel docs-panel"> <section className="panel docs-viewer">
<div className="panel-heading"> <aside className="docs-sidebar">
<div> <input
<p className="eyebrow">SERVICE DOCUMENTATION</p> className="docs-search"
<h2>API Documentation & References</h2> placeholder="Filter docs…"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
{groups.map((group) => (
<div className="docs-group" key={group}>
<p className="docs-group-title">
{group === "(top level)" ? "General" : group}
</p>
{visible
.filter((d) => d.group === group)
.map((d) => (
<button
key={d.rel}
className={`docs-nav-item ${d.rel === doc.rel ? "active" : ""}`}
onClick={() => setSelected(d.rel)}
>
{d.label}
<small>{d.file}</small>
</button>
))}
</div> </div>
<span className="panel-count"> ))}
{Object.keys(serviceMeta).length} services </aside>
<div className="docs-content">
<div className="docs-content-head">
<div className="panel-heading" style={{ marginBottom: 0 }}>
<div>
<p className="eyebrow">MARKDOWN DOCUMENTATION</p>
<h2>{doc ? doc.label : "No document selected"}</h2>
</div>
</div>
<span className="docs-source">
{doc ? `docs/${doc.rel}` : ""}
</span> </span>
</div> </div>
<div className="docs-grid"> {doc ? (
{Object.entries(serviceMeta).map(([key, meta]) => ( <article
<div className={`doc-card ${meta.color}`} key={key}> className="markdown-body"
<div className="doc-header"> ref={contentRef}
<span className={`service-dot ${meta.color}`} /> dangerouslySetInnerHTML={{ __html: html }}
<div> />
<strong>{meta.label}</strong> ) : (
<p>{meta.role}</p> <div className="empty">
<strong>No documents</strong>
<span>No markdown files were bundled with the dashboard.</span>
</div> </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> </div>
</section> </section>
); );
+41
View File
@@ -0,0 +1,41 @@
const entries = import.meta.glob("../docs/**/*.md", {
as: "raw",
eager: true,
});
export const docsIndex = Object.entries(entries)
.map(([path, content]) => {
const rel = path.replace("../docs/", "");
const parts = rel.split("/");
const file = parts[parts.length - 1];
const group =
parts.length > 1 ? parts.slice(0, -1).join("/") : "(top level)";
return {
rel,
group,
file,
label: file.replace(/\.md$/, "").replace(/_/g, " "),
content,
};
})
.sort((a, b) => a.rel.localeCompare(b.rel));
const groups = [...new Set(docsIndex.map((d) => d.group))];
export const docGroups = [
"(top level)",
...groups.filter((g) => g !== "(top level)").sort(),
];
export function resolveDocRel(fromRel, href) {
if (!href) return null;
const clean = href.split("#")[0] || "";
if (!clean.endsWith(".md")) return null;
const dirParts = fromRel.split("/").slice(0, -1);
for (const part of clean.split("/")) {
if (!part || part === ".") continue;
if (part === "..") dirParts.pop();
else dirParts.push(part);
}
const resolved = dirParts.join("/");
return docsIndex.some((d) => d.rel === resolved) ? resolved : null;
}
+181 -50
View File
@@ -647,72 +647,203 @@ textarea:focus {
.docs-panel { .docs-panel {
min-height: 500px; min-height: 500px;
} }
.docs-grid {
.docs-viewer {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); grid-template-columns: 280px 1fr;
gap: 16px; min-height: 560px;
padding: 0;
overflow: hidden;
} }
.doc-card { .docs-sidebar {
background: rgba(23, 32, 34, 0.88); background: #141c1e;
border: 1px solid var(--line); border-right: 1px solid var(--line);
border-radius: 6px; padding: 18px;
padding: 20px; max-height: 74vh;
border-top: 2px solid var(--card-color); overflow-y: auto;
} }
.doc-card.amber { --card-color: var(--amber); } .docs-search {
.doc-card.teal { --card-color: var(--mint); } width: 100%;
.doc-card.blue { --card-color: var(--blue); } padding: 8px 10px;
.doc-card.coral { --card-color: var(--coral); } font-size: 12px;
.doc-header { }
display: flex; .docs-group {
gap: 12px;
align-items: start;
margin-bottom: 12px; margin-bottom: 12px;
} }
.doc-header .service-dot { .docs-group-title {
flex-shrink: 0; font: 600 10px "DM Mono", monospace;
margin-top: 2px; text-transform: uppercase;
} letter-spacing: 0.12em;
.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); color: var(--muted);
margin: 18px 0 6px;
}
.docs-group:first-child .docs-group-title {
margin-top: 0;
}
.docs-nav-item {
display: block;
width: 100%;
text-align: left;
background: transparent;
border: 0;
color: #c6d5d3;
font-size: 12px; font-size: 12px;
line-height: 1.5; padding: 7px 9px;
margin-bottom: 14px; border-radius: 4px;
cursor: pointer;
line-height: 1.3;
} }
.doc-links { .docs-nav-item:hover {
background: #1d282a;
color: var(--mint);
}
.docs-nav-item.active {
background: rgba(138, 232, 194, 0.12);
color: var(--mint);
}
.docs-nav-item small {
display: block;
color: var(--muted);
font: 10px "DM Mono", monospace;
}
.docs-content {
padding: 22px 28px 32px;
min-width: 0;
max-height: 74vh;
overflow-y: auto;
}
.docs-content-head {
display: flex; display: flex;
flex-direction: column; justify-content: space-between;
gap: 12px; align-items: baseline;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 10px;
} }
.doc-link { .docs-source {
font: 10px "DM Mono", monospace;
color: var(--muted);
white-space: nowrap;
}
.markdown-body {
color: #d3dddd;
font-size: 14px;
line-height: 1.65;
word-wrap: break-word;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4 {
color: #e7eeee;
letter-spacing: -0.02em;
margin: 1.5em 0 0.5em;
scroll-margin-top: 20px;
}
.markdown-body h1 {
font-size: 24px;
padding-bottom: 8px;
border-bottom: 1px solid var(--line);
}
.markdown-body h2 {
font-size: 19px;
}
.markdown-body h3 {
font-size: 15px;
}
.markdown-body p {
margin: 0.7em 0;
}
.markdown-body a {
color: var(--mint); color: var(--mint);
text-decoration: none; text-decoration: none;
font: 500 11px "DM Mono", monospace;
display: inline-flex;
align-items: center;
gap: 6px;
} }
.doc-link:hover { text-decoration: underline; } .markdown-body a:hover {
.routes-detail summary { text-decoration: underline;
color: var(--muted);
font: 500 10px "DM Mono", monospace;
cursor: pointer;
text-transform: uppercase;
} }
.routes-detail ul { .markdown-body ul,
margin: 8px 0 0; .markdown-body ol {
padding-left: 18px; padding-left: 22px;
font-size: 11px; margin: 0.7em 0;
color: #c6d5d3;
} }
.routes-detail li { margin: 4px 0; } .markdown-body li {
.routes-detail code { margin: 0.32em 0;
font: 11px "DM Mono", monospace; }
.markdown-body li > ul,
.markdown-body li > ol {
margin: 0.2em 0;
}
.markdown-body code {
font: 0.9em "DM Mono", monospace;
background: #111a1c; background: #111a1c;
padding: 2px 4px; padding: 2px 5px;
border-radius: 2px; border-radius: 3px;
color: var(--amber);
}
.markdown-body pre {
background: #0d1416;
border: 1px solid var(--line);
border-radius: 6px;
padding: 14px 16px;
overflow: auto;
line-height: 1.45;
}
.markdown-body pre code {
background: none;
padding: 0;
color: #d3dddd;
font-size: 12px;
}
.markdown-body blockquote {
margin: 1em 0;
padding: 0.6em 14px;
border-left: 3px solid var(--blue);
background: rgba(126, 183, 245, 0.06);
color: var(--muted);
}
.markdown-body table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
font-size: 13px;
}
.markdown-body th,
.markdown-body td {
border: 1px solid var(--line);
padding: 8px 10px;
text-align: left;
vertical-align: top;
}
.markdown-body th {
background: #1a2527;
color: #e7eeee;
font-weight: 600;
}
.markdown-body hr {
border: 0;
border-top: 1px solid var(--line);
margin: 1.6em 0;
}
.markdown-body img {
max-width: 100%;
border-radius: 4px;
}
.markdown-body > :first-child {
margin-top: 0;
}
@media (max-width: 900px) {
.docs-viewer {
grid-template-columns: 1fr;
}
.docs-sidebar {
border-right: 0;
border-bottom: 1px solid var(--line);
max-height: 38vh;
}
.docs-content {
max-height: none;
padding: 20px;
}
} }
@media (max-width: 700px) { @media (max-width: 700px) {