Skip to content

Commit b26e32b

Browse files
committed
feat(hub): API docs sidebar group with hub-proxied Swagger UIs
Adds a new "API docs" sidebar group linking to the crawler + git-metadata-extractor Swagger UIs, routed through the hub at /api/crawler/docs and /api/extractor/docs so discovery is gated by HUB_AUTH instead of being anonymously reachable on the upstream ports. The proxied openapi.json rewrites the `servers` field to the public upstream URL so Swagger UI's "Try it out" still works once the user pastes their CRAWLER_API_TOKEN / EXTRACTOR_API_TOKEN into Authorize. User-facing URLs derive from a single HUB_PUBLIC_HOST env var (default `localhost`); per-service overrides still win. Sidebar links are tightened to a uniform 28px height — dashboards collapsed from a 2-line summary to a single-line row with the summary moved into the link's title= tooltip.
1 parent 915151f commit b26e32b

8 files changed

Lines changed: 239 additions & 41 deletions

File tree

infra/.env.example

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,14 @@ GITHUB_API_TOKEN=
187187
GITLAB_API_TOKEN=
188188
# Optional: separate PAT for the grimoire intake cron sidecar.
189189
# GRIMOIRE_GITHUB_TOKEN=
190+
191+
# Public hostname users hit in the browser. The hub builds every
192+
# HUB_*_BROWSER_URL and HUB_*_DOCS_URL default by gluing this onto the
193+
# host-published port. Override an individual URL below only if a service
194+
# lives on a different host or sits behind a reverse proxy.
195+
# HUB_PUBLIC_HOST=openpulse.epfl.ch
196+
# HUB_NEO4J_BROWSER_URL=
197+
# HUB_SPARQL_BROWSER_URL=
198+
# HUB_OPENSEARCH_DASHBOARDS_URL=
199+
# HUB_CRAWLER_DOCS_URL=
200+
# HUB_EXTRACTOR_DOCS_URL=

infra/open-pulse-stack/docker-compose.yml

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,10 @@ services:
329329
HUB_APPLIER_URL: "${HUB_APPLIER_URL:-http://projects-applier:8000}"
330330
HUB_SPARQL_URL: "${HUB_SPARQL_URL:-http://sparql-proxy:7878}"
331331
HUB_NEO4J_URL: "${HUB_NEO4J_URL:-bolt://neo4j:7687}"
332-
HUB_KIBITER_URL: "${HUB_KIBITER_URL:-http://localhost:7508}"
332+
HUB_KIBITER_URL: "${HUB_KIBITER_URL:-http://${HUB_PUBLIC_HOST:-localhost}:7508}"
333+
# Single host knob the URL defaults below derive from. Override individual
334+
# HUB_*_BROWSER_URL / HUB_*_DOCS_URL values to bypass this.
335+
HUB_PUBLIC_HOST: "${HUB_PUBLIC_HOST:-localhost}"
333336
# OpenSearch (used by /api/databases/opensearch/* and the marquee stats)
334337
HUB_OPENSEARCH_URL: "${HUB_OPENSEARCH_URL:-https://opensearch-node1:9200}"
335338
HUB_OPENSEARCH_USERNAME: "${HUB_OPENSEARCH_USERNAME:-${OPENSEARCH_USERNAME:-admin}}"
@@ -346,11 +349,24 @@ services:
346349
SPARQL_AUTH: "${SPARQL_AUTH:-}"
347350
# Used by /api/projects/build-by-owner to query Neo4j directly.
348351
NEO4J_AUTH: "${NEO4J_AUTH:-}"
349-
# User-facing dashboard URLs (host-published ports / external links)
350-
HUB_NEO4J_BROWSER_URL: "${HUB_NEO4J_BROWSER_URL:-http://localhost:7474}"
351-
HUB_SPARQL_BROWSER_URL: "${HUB_SPARQL_BROWSER_URL:-http://localhost:7878}"
352-
HUB_OPENSEARCH_DASHBOARDS_URL: "${HUB_OPENSEARCH_DASHBOARDS_URL:-http://localhost:5601}"
352+
# User-facing dashboard URLs (host-published ports / external links).
353+
# Default to HUB_PUBLIC_HOST so an operator only has to set one var;
354+
# set an explicit URL here to override on a per-service basis.
355+
HUB_NEO4J_BROWSER_URL: "${HUB_NEO4J_BROWSER_URL:-http://${HUB_PUBLIC_HOST:-localhost}:7503}"
356+
HUB_SPARQL_BROWSER_URL: "${HUB_SPARQL_BROWSER_URL:-http://${HUB_PUBLIC_HOST:-localhost}:7502}"
357+
HUB_OPENSEARCH_DASHBOARDS_URL: "${HUB_OPENSEARCH_DASHBOARDS_URL:-http://${HUB_PUBLIC_HOST:-localhost}:7508}"
353358
HUB_ONTOLOGY_URL: "${HUB_ONTOLOGY_URL:-https://github.com/sdsc-ordes/open-pulse-ontology}"
359+
# Swagger UIs are proxied through the hub (auth-gated) by default, so
360+
# the upstream pages don't need to be reachable anonymously. Set a full
361+
# URL to bypass the hub proxy.
362+
HUB_CRAWLER_DOCS_URL: "${HUB_CRAWLER_DOCS_URL:-/api/crawler/docs}"
363+
HUB_EXTRACTOR_DOCS_URL: "${HUB_EXTRACTOR_DOCS_URL:-/api/extractor/docs}"
364+
# Public URL Swagger UI's "Try it out" hits — used in the rewritten
365+
# `servers` field. Defaults to ${HUB_PUBLIC_HOST}:${PORT}.
366+
HUB_CRAWLER_PUBLIC_URL: "${HUB_CRAWLER_PUBLIC_URL:-}"
367+
HUB_EXTRACTOR_PUBLIC_URL: "${HUB_EXTRACTOR_PUBLIC_URL:-}"
368+
# Used by the openapi proxy to fetch the spec in-network.
369+
HUB_EXTRACTOR_URL: "${HUB_EXTRACTOR_URL:-http://git-metadata-extractor:1234}"
354370
volumes:
355371
- /var/run/docker.sock:/var/run/docker.sock
356372
- ${OPEN_PULSE_DATA_DIR:-./data}/hub:/data/hub

src/open_pulse/gui/hub/config.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ class Settings:
5656
ontology_url: str
5757
"""External URL of the open-pulse ontology documentation."""
5858

59+
crawler_docs_url: str
60+
"""User-facing URL of the crawler's OpenAPI / Swagger docs (host-published)."""
61+
62+
extractor_docs_url: str
63+
"""User-facing URL of the git-metadata-extractor's Swagger docs
64+
(host-published)."""
65+
5966
opensearch_url: str
6067
"""Base URL of OpenSearch (in-network)."""
6168

@@ -115,13 +122,20 @@ def load_settings() -> Settings:
115122
"(e.g. python -c 'import secrets; print(secrets.token_urlsafe(32))')."
116123
)
117124

125+
# User-facing hostname for the host-published service ports. The
126+
# individual HUB_*_BROWSER_URL / HUB_*_DOCS_URL overrides still win;
127+
# this is just the default so an operator only has to set one var.
128+
public_host = os.environ.get("HUB_PUBLIC_HOST", "localhost").strip() or "localhost"
129+
118130
return Settings(
119131
auth_token=auth,
120132
data_dir=Path(os.environ.get("HUB_DATA_DIR", "/data/hub")),
121133
applier_url=os.environ.get("HUB_APPLIER_URL", "http://projects-applier:8000"),
122134
sparql_url=os.environ.get("HUB_SPARQL_URL", "http://sparql-proxy:7878"),
123135
neo4j_url=os.environ.get("HUB_NEO4J_URL", "bolt://neo4j:7687"),
124-
grimoire_kibiter_url=os.environ.get("HUB_KIBITER_URL", "http://localhost:7508"),
136+
grimoire_kibiter_url=os.environ.get(
137+
"HUB_KIBITER_URL", f"http://{public_host}:7508"
138+
),
125139
opensearch_url=os.environ.get(
126140
"HUB_OPENSEARCH_URL", "https://opensearch-node1:9200"
127141
),
@@ -133,17 +147,25 @@ def load_settings() -> Settings:
133147
),
134148
opensearch_verify_tls=_env_bool("HUB_OPENSEARCH_VERIFY_TLS", default=False),
135149
neo4j_browser_url=os.environ.get(
136-
"HUB_NEO4J_BROWSER_URL", "http://localhost:7474"
150+
"HUB_NEO4J_BROWSER_URL", f"http://{public_host}:7503"
137151
),
138152
sparql_browser_url=os.environ.get(
139-
"HUB_SPARQL_BROWSER_URL", "http://localhost:7878"
153+
"HUB_SPARQL_BROWSER_URL", f"http://{public_host}:7502"
140154
),
141155
opensearch_dashboards_url=os.environ.get(
142-
"HUB_OPENSEARCH_DASHBOARDS_URL", "http://localhost:5601"
156+
"HUB_OPENSEARCH_DASHBOARDS_URL", f"http://{public_host}:7508"
143157
),
144158
ontology_url=os.environ.get(
145159
"HUB_ONTOLOGY_URL", "https://github.com/sdsc-ordes/open-pulse-ontology"
146160
),
161+
# Default to the hub-proxied Swagger surfaces (routes/crawler.py +
162+
# routes/extractor.py) — hub auth gates discovery, so we don't have
163+
# to expose the upstream Swagger pages anonymously on their public
164+
# ports. Override with a full URL to bypass the proxy.
165+
crawler_docs_url=os.environ.get("HUB_CRAWLER_DOCS_URL", "/api/crawler/docs"),
166+
extractor_docs_url=os.environ.get(
167+
"HUB_EXTRACTOR_DOCS_URL", "/api/extractor/docs"
168+
),
147169
applier_auth=os.environ.get("APPLIER_AUTH", "").strip(),
148170
sparql_user=(_parse_user_pass(os.environ.get("SPARQL_AUTH", ""))[0]),
149171
sparql_password=(_parse_user_pass(os.environ.get("SPARQL_AUTH", ""))[1]),

src/open_pulse/gui/hub/main.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,17 @@
1313
from fastapi.templating import Jinja2Templates
1414

1515
from .auth import _COOKIE_NAME, clear_session, get_settings, require_auth
16-
from .routes import admin, crawler, databases, pipeline, projects, services, stack, stats
16+
from .routes import (
17+
admin,
18+
crawler,
19+
databases,
20+
extractor,
21+
pipeline,
22+
projects,
23+
services,
24+
stack,
25+
stats,
26+
)
1727

1828
_HERE = Path(__file__).parent
1929
log = logging.getLogger(__name__)
@@ -64,6 +74,21 @@ async def _lifespan(app: FastAPI):
6474
},
6575
]
6676
templates.env.globals["ontology_url"] = _settings.ontology_url
77+
# Swagger UIs for the two pipeline services that ship an HTTP API. Rendered
78+
# as their own compact sidebar group so users can poke the endpoints
79+
# directly without digging through Portainer.
80+
templates.env.globals["api_docs"] = [
81+
{
82+
"name": "Crawler",
83+
"tech": "FastAPI",
84+
"url": _settings.crawler_docs_url,
85+
},
86+
{
87+
"name": "Metadata extractor",
88+
"tech": "FastAPI",
89+
"url": _settings.extractor_docs_url,
90+
},
91+
]
6792

6893
app.include_router(services.router)
6994
app.include_router(projects.router)
@@ -72,6 +97,7 @@ async def _lifespan(app: FastAPI):
7297
app.include_router(stack.router)
7398
app.include_router(stats.router)
7499
app.include_router(crawler.router)
100+
app.include_router(extractor.router)
75101
app.include_router(admin.router)
76102

77103

src/open_pulse/gui/hub/routes/crawler.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@
88

99
from __future__ import annotations
1010

11+
import json
1112
import os
1213
from typing import Any
1314

1415
import httpx
15-
from fastapi import APIRouter, Depends, HTTPException
16+
from fastapi import APIRouter, Depends, HTTPException, Response
17+
from fastapi.openapi.docs import get_swagger_ui_html
18+
from fastapi.responses import HTMLResponse
1619

1720
from ..auth import require_auth
1821

@@ -68,6 +71,52 @@ def _passthrough(method: str, path: str, **kw: Any) -> dict[str, Any]:
6871
return resp.json() if resp.content else {}
6972

7073

74+
def _crawler_public_url() -> str:
75+
"""External base URL Swagger UI's "Try it out" hits.
76+
77+
Defaults to ``http://${HUB_PUBLIC_HOST}:${CRAWLER_PORT}`` so a single
78+
knob keeps everything pointing at the right host. Override with
79+
``HUB_CRAWLER_PUBLIC_URL`` if the crawler sits behind a different
80+
proxy (e.g. https + path-prefix).
81+
"""
82+
explicit = os.environ.get("HUB_CRAWLER_PUBLIC_URL", "").strip()
83+
if explicit:
84+
return explicit.rstrip("/")
85+
host = os.environ.get("HUB_PUBLIC_HOST", "localhost").strip() or "localhost"
86+
port = os.environ.get("CRAWLER_PORT", "8000").strip() or "8000"
87+
return f"http://{host}:{port}"
88+
89+
90+
@router.get("/docs", include_in_schema=False, dependencies=[Depends(require_auth)])
91+
def crawler_docs() -> HTMLResponse:
92+
"""Hub-gated Swagger UI for the crawler API. Reads the spec from
93+
``/api/crawler/openapi.json`` (also hub-gated). Authentication is the
94+
user's hub session — no upstream token needed to *view* the surface.
95+
"""
96+
return get_swagger_ui_html(
97+
openapi_url="/api/crawler/openapi.json",
98+
title="Crawler API — via Open Pulse Hub",
99+
)
100+
101+
102+
@router.get(
103+
"/openapi.json", include_in_schema=False, dependencies=[Depends(require_auth)]
104+
)
105+
def crawler_openapi() -> Response:
106+
"""Proxy the upstream spec and rewrite ``servers`` to the crawler's
107+
public URL so Swagger UI's "Try it out" hits the upstream directly
108+
(the user pastes ``CRAWLER_API_TOKEN`` into the Authorize dialog).
109+
"""
110+
try:
111+
upstream = httpx.get(f"{_crawler_base()}/api/v1/openapi.json", timeout=5.0)
112+
upstream.raise_for_status()
113+
except httpx.HTTPError as exc:
114+
raise HTTPException(status_code=502, detail=str(exc)) from exc
115+
spec = upstream.json()
116+
spec["servers"] = [{"url": _crawler_public_url()}]
117+
return Response(content=json.dumps(spec), media_type="application/json")
118+
119+
71120
@router.get("/jobs", dependencies=[Depends(require_auth)])
72121
def list_jobs() -> dict[str, Any]:
73122
return _passthrough("GET", "/api/v1/jobs")
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Hub-gated proxy for the git-metadata-extractor (GME) Swagger UI.
2+
3+
GME ships its OpenAPI spec at ``/openapi.json`` and Swagger UI at
4+
``/docs``. The upstream API endpoints are bearer-token protected, but
5+
the docs themselves are public on the published port. Proxying through
6+
the hub means hub-authenticated users can discover the API surface
7+
without us also having to expose the upstream port to the world.
8+
9+
Only the read-only docs surface is proxied here — Swagger UI's
10+
"Try it out" is wired to hit the upstream public URL directly (with the
11+
user's GME bearer pasted into Authorize), so we don't need to forward
12+
every API path through the hub.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import json
18+
import os
19+
20+
import httpx
21+
from fastapi import APIRouter, Depends, HTTPException, Response
22+
from fastapi.openapi.docs import get_swagger_ui_html
23+
from fastapi.responses import HTMLResponse
24+
25+
from ..auth import require_auth
26+
27+
router = APIRouter(prefix="/api/extractor", tags=["extractor"])
28+
29+
30+
def _extractor_base() -> str:
31+
"""In-network URL of the GME container."""
32+
return os.environ.get(
33+
"HUB_EXTRACTOR_URL", "http://git-metadata-extractor:1234"
34+
).rstrip("/")
35+
36+
37+
def _extractor_public_url() -> str:
38+
"""External base URL Swagger UI's "Try it out" hits."""
39+
explicit = os.environ.get("HUB_EXTRACTOR_PUBLIC_URL", "").strip()
40+
if explicit:
41+
return explicit.rstrip("/")
42+
host = os.environ.get("HUB_PUBLIC_HOST", "localhost").strip() or "localhost"
43+
port = os.environ.get("EXTRACTOR_PORT", "1234").strip() or "1234"
44+
return f"http://{host}:{port}"
45+
46+
47+
@router.get("/docs", include_in_schema=False, dependencies=[Depends(require_auth)])
48+
def extractor_docs() -> HTMLResponse:
49+
return get_swagger_ui_html(
50+
openapi_url="/api/extractor/openapi.json",
51+
title="Metadata extractor API — via Open Pulse Hub",
52+
)
53+
54+
55+
@router.get(
56+
"/openapi.json", include_in_schema=False, dependencies=[Depends(require_auth)]
57+
)
58+
def extractor_openapi() -> Response:
59+
try:
60+
upstream = httpx.get(f"{_extractor_base()}/openapi.json", timeout=5.0)
61+
upstream.raise_for_status()
62+
except httpx.HTTPError as exc:
63+
raise HTTPException(status_code=502, detail=str(exc)) from exc
64+
spec = upstream.json()
65+
spec["servers"] = [{"url": _extractor_public_url()}]
66+
return Response(content=json.dumps(spec), media_type="application/json")

src/open_pulse/gui/hub/static/app.css

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ body {
183183
border-top: 1px solid var(--border);
184184
}
185185
.sidebar-group-label {
186-
padding: 10px 12px 4px;
186+
padding: 8px 10px 2px;
187187
font-size: 10px;
188188
font-weight: 600;
189189
text-transform: uppercase;
@@ -199,16 +199,19 @@ body {
199199
flex-shrink: 0;
200200
}
201201
.sidebar-link-ext:hover .ext-arrow { color: var(--accent); }
202-
/* Dashboard rows are taller — they include a tech badge + a one-line
203-
summary so users know which surface is which. */
204-
.sidebar-link-dash {
205-
align-items: flex-start;
206-
padding-top: 10px;
207-
padding-bottom: 10px;
208-
}
209-
.sidebar-link-dash .dash-meta { flex: 1; min-width: 0; }
210-
.sidebar-link-dash .dash-head { display: flex; align-items: baseline; gap: 6px; }
211-
.sidebar-link-dash .dash-name { font-weight: 600; color: var(--fg); }
202+
/* Dashboard + API-docs rows: single-line, same height as a regular
203+
sidebar-link. The tech badge sits inline after the name; the full
204+
summary moves into the row's title= tooltip so users can hover for
205+
detail without the link itself growing two lines tall. */
206+
.sidebar-link-dash .dash-name {
207+
font-weight: 600;
208+
color: var(--fg);
209+
flex-shrink: 1;
210+
min-width: 0;
211+
overflow: hidden;
212+
text-overflow: ellipsis;
213+
white-space: nowrap;
214+
}
212215
.sidebar-link-dash .dash-tech {
213216
font-size: 9px;
214217
text-transform: uppercase;
@@ -217,20 +220,16 @@ body {
217220
border-radius: 3px;
218221
background: var(--accent-soft);
219222
color: var(--accent);
220-
}
221-
.sidebar-link-dash .dash-summary {
222-
font-size: 11px;
223-
color: var(--fg-faint);
224-
margin-top: 1px;
225-
line-height: 1.35;
226-
white-space: normal;
223+
flex-shrink: 0;
227224
}
228225
.sidebar-nav { overflow-y: auto; } /* let the nav scroll if 3 groups overflow */
229226
.sidebar-link {
230-
display: flex; align-items: center; gap: 10px;
231-
padding: 8px 12px;
232-
border-radius: 8px;
233-
font-size: 13px; font-weight: 500;
227+
display: flex; align-items: center; gap: 9px;
228+
padding: 5px 10px;
229+
border-radius: 6px;
230+
font-size: 12.5px; font-weight: 500;
231+
line-height: 1.25;
232+
min-height: 28px;
234233
color: var(--fg-muted);
235234
text-decoration: none;
236235
border: 1px solid transparent;

0 commit comments

Comments
 (0)