Serve documentation from the operator UI
This commit is contained in:
@@ -121,7 +121,7 @@ func run() error {
|
||||
|
||||
// pool.Ping backs /health: readiness means the database answers, not just
|
||||
// that the process is alive.
|
||||
api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, cfg.UserserviceURL, m, pool.Ping, cfg.PublicCoordinatorURL, cfg.PublicUserserviceURL)
|
||||
api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, cfg.UserserviceURL, m, pool.Ping, cfg.PublicCoordinatorURL, cfg.PublicUserserviceURL, cfg.DocsDir)
|
||||
err = infra.RunServer(ctx, log, cfg.Addr, api.Handler(cfg.Token, cfg.UIToken))
|
||||
|
||||
// Shutdown order matters, and defers alone cannot express it (they run
|
||||
|
||||
@@ -51,6 +51,9 @@ services:
|
||||
WORKER_AUTH_TOKEN: ${WORKER_AUTH_TOKEN:-dev-token}
|
||||
# Empty disables /ui. Set this separately from the worker token.
|
||||
UI_AUTH_TOKEN: ${UI_AUTH_TOKEN:-}
|
||||
# Directory of the built MkDocs site served at /ui/docs/ (empty disables
|
||||
# the docs route; the demo mounts ./site automatically).
|
||||
SCIMESH_DOCS_DIR: ${SCIMESH_DOCS_DIR:-}
|
||||
DB_MAX_CONNS: "10"
|
||||
REQUEST_TIMEOUT: "15s"
|
||||
LEASE_DURATION: "2m"
|
||||
|
||||
@@ -51,6 +51,9 @@ type Config struct {
|
||||
LogFile string
|
||||
// Directory where artifact bytes are stored.
|
||||
StorageDir string
|
||||
// Directory of the built MkDocs site (site/) served at /ui/docs/. Empty
|
||||
// disables the docs route; the UI shows a hint page instead.
|
||||
DocsDir string
|
||||
// Upper bound on an uploaded dataset or artifact body, in bytes.
|
||||
MaxUploadBytes int64
|
||||
|
||||
@@ -108,6 +111,7 @@ func LoadConfig() (Config, error) {
|
||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||
LogFile: os.Getenv("LOG_FILE"),
|
||||
StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"),
|
||||
DocsDir: os.Getenv("SCIMESH_DOCS_DIR"),
|
||||
MaxUploadBytes: 1 << 30, // 1 GiB
|
||||
DBMaxConns: 10,
|
||||
DBConnectTimeout: 30 * time.Second,
|
||||
|
||||
@@ -56,6 +56,8 @@ type Server struct {
|
||||
publicUserserviceURL string
|
||||
// httpClient makes the login/register calls to the userservice.
|
||||
httpClient *http.Client
|
||||
// docsDir serves the built MkDocs site at /ui/docs/. Empty disables it.
|
||||
docsDir string
|
||||
// metrics holds the Prometheus registry and HTTP instrumentation.
|
||||
metrics *metrics.Metrics
|
||||
// ready probes downstream dependencies (the database) for /health. Kept as
|
||||
@@ -78,6 +80,10 @@ func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval
|
||||
if len(publicURLs) > 1 {
|
||||
publicUserserviceURL = strings.TrimRight(publicURLs[1], "/")
|
||||
}
|
||||
docsDir := ""
|
||||
if len(publicURLs) > 2 {
|
||||
docsDir = publicURLs[2]
|
||||
}
|
||||
return &Server{
|
||||
uc: uc,
|
||||
log: log,
|
||||
@@ -88,6 +94,7 @@ func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval
|
||||
userserviceURL: strings.TrimRight(userserviceURL, "/"),
|
||||
publicCoordinatorURL: publicCoordinatorURL,
|
||||
publicUserserviceURL: publicUserserviceURL,
|
||||
docsDir: docsDir,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
metrics: m,
|
||||
ready: ready,
|
||||
@@ -136,6 +143,8 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler {
|
||||
{"GET /ui", s.handleUIHome},
|
||||
{"GET /ui/jobs/new", s.handleUINewJob},
|
||||
{"GET /ui/workloads", s.handleUIWorkloads},
|
||||
{"GET /ui/docs", s.handleUIDocsIndex},
|
||||
{"GET /ui/docs/{path...}", s.handleUIDocs},
|
||||
{"GET /ui/jobs/{job_id}", s.handleUIJob},
|
||||
{"GET /ui/api/overview", s.handleUIOverviewJSON},
|
||||
{"GET /ui/api/jobs/{job_id}", s.handleUIJobJSON},
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<main class="page">
|
||||
<header class="top">
|
||||
<div><p class="eyebrow">Local scientific compute</p><h1 class="title">SciMesh control room</h1><p class="lead">Follow the real path from a molecular TSV to a globally reduced similarity result—without reading coordinator logs.</p><div class="live"><i class="pulse"></i><span id="refresh-state">Live overview · refreshes every 2 seconds</span></div></div>
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">{{if .Session}}<span class="live" style="margin-top:0">Signed in · {{.Session.Role}}</span>{{end}}{{if .Session}}<a class="button" href="/ui/profile" style="background:#23344d;color:#dce8ff;box-shadow:none">Profile</a>{{end}}{{if and .Session (eq .Session.Role "admin")}}<a class="button" href="/ui/admin" style="background:#23344d;color:#dce8ff;box-shadow:none">Admin</a>{{end}}{{if .Session}}<a class="button" href="/ui/workloads" style="background:#23344d;color:#dce8ff;box-shadow:none">Workloads</a>{{end}}{{if .Session}}<a class="button" href="/ui/workers/new" style="background:#23344d;color:#dce8ff;box-shadow:none">🖥 Add your machine</a>{{end}}<a class="button" href="/ui/jobs/new">+ New similarity search</a>{{if .Session}}<form method="post" action="/ui/logout" style="margin:0"><button class="button" type="submit" style="background:#23344d;color:#dce8ff;box-shadow:none">Log out</button></form>{{end}}</div>
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">{{if .Session}}<span class="live" style="margin-top:0">Signed in · {{.Session.Role}}</span>{{end}}{{if .Session}}<a class="button" href="/ui/profile" style="background:#23344d;color:#dce8ff;box-shadow:none">Profile</a>{{end}}{{if and .Session (eq .Session.Role "admin")}}<a class="button" href="/ui/admin" style="background:#23344d;color:#dce8ff;box-shadow:none">Admin</a>{{end}}{{if .Session}}<a class="button" href="/ui/workloads" style="background:#23344d;color:#dce8ff;box-shadow:none">Workloads</a>{{end}}{{if .Session}}<a class="button" href="/ui/docs/" style="background:#23344d;color:#dce8ff;box-shadow:none">Docs</a>{{end}}{{if .Session}}<a class="button" href="/ui/workers/new" style="background:#23344d;color:#dce8ff;box-shadow:none">🖥 Add your machine</a>{{end}}<a class="button" href="/ui/jobs/new">+ New similarity search</a>{{if .Session}}<form method="post" action="/ui/logout" style="margin:0"><button class="button" type="submit" style="background:#23344d;color:#dce8ff;box-shadow:none">Log out</button></form>{{end}}</div>
|
||||
</header>
|
||||
<section class="summary" aria-label="Pipeline summary">
|
||||
<div class="panel"><strong>How a search becomes a result</strong><div class="pipeline"><div class="flow-step"><span><i class="dot"></i>01</span><b>Upload TSV</b><small>The coordinator validates and slices the dataset.</small></div><div class="flow-step"><span><i class="dot"></i>02</span><b>Run shards</b><small>Workers fingerprint molecules and return shard top-k CSVs.</small></div><div class="flow-step"><span><i class="dot"></i>03</span><b>Merge exactly</b><small>The coordinator ranks retained candidates deterministically.</small></div><div class="flow-step"><span><i class="dot"></i>04</span><b>Download CSV</b><small>A checksum-protected global result is ready.</small></div></div></div>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{{define "docs-unavailable.html"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Documentation · SciMesh</title>
|
||||
<style>
|
||||
:root{color:#e5efff;background:#08111f;font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;color-scheme:dark}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 12% -8%,#183f77 0,transparent 32rem),#08111f}.page{max-width:760px;margin:auto;padding:80px 22px}a{color:#94bdff}.eyebrow{margin:0 0 4px;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:0;color:#f4f8ff;font-size:clamp(2rem,5vw,3rem);letter-spacing:-.055em}.card{margin-top:26px;border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021;padding:22px}.card p{color:#b9c9e2}code{background:#0a1626;border:1px solid #2b4a6b;border-radius:6px;padding:2px 7px;color:#b5d3f5;font-size:.88em}.back{display:inline-block;margin-top:22px;text-decoration:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<p class="eyebrow">MkDocs site</p>
|
||||
<h1>Documentation is not available</h1>
|
||||
<div class="card">
|
||||
<p>The documentation site has not been built or the coordinator has not been pointed at it. From the repository root, run:</p>
|
||||
<p><code>make docs</code> then restart the coordinator with <code>SCIMESH_DOCS_DIR</code> set to the generated <code>site/</code> directory (the <code>make demo-ui</code> demo does this automatically).</p>
|
||||
<a class="back" href="/ui">← Back to the control room</a>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,53 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// handleUIDocsIndex redirects /ui/docs to the trailing-slash form so the
|
||||
// wildcard route below can resolve index.html.
|
||||
func (s *Server) handleUIDocsIndex(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/ui/docs/", http.StatusPermanentRedirect)
|
||||
}
|
||||
|
||||
// handleUIDocs serves the built MkDocs site (site/) as static files. The
|
||||
// configured docs directory is an operator-supplied path, never derived from
|
||||
// a request; path traversal is rejected by joining against the cleaned root
|
||||
// and checking the result stays inside it.
|
||||
func (s *Server) handleUIDocs(w http.ResponseWriter, r *http.Request) {
|
||||
if s.docsDir == "" {
|
||||
s.renderUI(w, "docs-unavailable.html", nil)
|
||||
return
|
||||
}
|
||||
root, err := filepath.Abs(s.docsDir)
|
||||
if err != nil {
|
||||
s.renderUI(w, "docs-unavailable.html", nil)
|
||||
return
|
||||
}
|
||||
clean := filepath.Clean(strings.TrimPrefix(r.URL.Path, "/ui/docs/"))
|
||||
target, err := filepath.Abs(filepath.Join(root, clean))
|
||||
if err != nil {
|
||||
s.renderUI(w, "docs-unavailable.html", nil)
|
||||
return
|
||||
}
|
||||
rel, err := filepath.Rel(root, target)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
info, err := os.Stat(target)
|
||||
if err != nil || info.IsDir() {
|
||||
if err == nil && info.IsDir() {
|
||||
target = filepath.Join(target, "index.html")
|
||||
info, err = os.Stat(target)
|
||||
}
|
||||
if err != nil || info.IsDir() {
|
||||
s.renderUI(w, "docs-unavailable.html", nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.ServeFile(w, r, target)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func docsTestServer(t *testing.T, docsDir string) *Server {
|
||||
t.Helper()
|
||||
return &Server{
|
||||
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
docsDir: docsDir,
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIDocsServesIndexAndNestedFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("<h1>Home</h1>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sub := filepath.Join(root, "api")
|
||||
if err := os.Mkdir(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sub, "page.html"), []byte("<h1>API page</h1>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := docsTestServer(t, root)
|
||||
|
||||
index := httptest.NewRecorder()
|
||||
server.handleUIDocs(index, httptest.NewRequest(http.MethodGet, "/ui/docs/", nil))
|
||||
if index.Code != http.StatusOK || !strings.Contains(index.Body.String(), "<h1>Home</h1>") {
|
||||
t.Fatalf("index = %d %q", index.Code, index.Body.String())
|
||||
}
|
||||
|
||||
page := httptest.NewRecorder()
|
||||
server.handleUIDocs(page, httptest.NewRequest(http.MethodGet, "/ui/docs/api/page.html", nil))
|
||||
if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "<h1>API page</h1>") {
|
||||
t.Fatalf("nested page = %d %q", page.Code, page.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIDocsRejectsPathTraversal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
secret := filepath.Join(root, "secret.txt")
|
||||
if err := os.WriteFile(secret, []byte("private"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := docsTestServer(t, root)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/ui/docs/../secret.txt", nil)
|
||||
request.URL.Path = "/ui/docs/../secret.txt"
|
||||
recorder := httptest.NewRecorder()
|
||||
server.handleUIDocs(recorder, request)
|
||||
if recorder.Code != http.StatusNotFound {
|
||||
t.Fatalf("traversal status = %d, want 404", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIDocsShowsBuildHintWhenDisabledOrMissing(t *testing.T) {
|
||||
disabled := docsTestServer(t, "")
|
||||
recorder := httptest.NewRecorder()
|
||||
disabled.handleUIDocs(recorder, httptest.NewRequest(http.MethodGet, "/ui/docs/", nil))
|
||||
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "Documentation is not available") {
|
||||
t.Fatalf("disabled docs = %d %q", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
missing := docsTestServer(t, filepath.Join(t.TempDir(), "does-not-exist"))
|
||||
recorder = httptest.NewRecorder()
|
||||
missing.handleUIDocs(recorder, httptest.NewRequest(http.MethodGet, "/ui/docs/", nil))
|
||||
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "Documentation is not available") {
|
||||
t.Fatalf("missing docs = %d %q", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIDocsIndexRedirectsToTrailingSlash(t *testing.T) {
|
||||
server := docsTestServer(t, t.TempDir())
|
||||
recorder := httptest.NewRecorder()
|
||||
server.handleUIDocsIndex(recorder, httptest.NewRequest(http.MethodGet, "/ui/docs", nil))
|
||||
if recorder.Code != http.StatusPermanentRedirect || recorder.Header().Get("Location") != "/ui/docs/" {
|
||||
t.Fatalf("redirect = %d %q", recorder.Code, recorder.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,29 @@ worker_bin=${SCIMESH_WORKER_BIN:-"$repo_dir/.venv/bin/scimesh-worker"}
|
||||
pid_file="$demo_dir/workers.pids"
|
||||
logs_dir="$demo_dir/logs"
|
||||
|
||||
# The built MkDocs site is mounted into the demo coordinator so the UI can
|
||||
# serve it at /ui/docs/. When site/ is missing (make docs), the docs route
|
||||
# shows a build hint instead.
|
||||
docs_compose_file="$demo_dir/docker-compose.docs.yml"
|
||||
docs_compose_files=""
|
||||
|
||||
prepare_docs_override() {
|
||||
mkdir -p "$demo_dir"
|
||||
if [[ -d "$repo_dir/site" ]]; then
|
||||
cat > "$docs_compose_file" <<DOCS_OVERRIDE_EOF
|
||||
services:
|
||||
coordinator:
|
||||
volumes:
|
||||
- $repo_dir/site:/site:ro
|
||||
environment:
|
||||
SCIMESH_DOCS_DIR: /site
|
||||
DOCS_OVERRIDE_EOF
|
||||
docs_compose_files="-f $docs_compose_file"
|
||||
else
|
||||
docs_compose_files=""
|
||||
fi
|
||||
}
|
||||
|
||||
compose() {
|
||||
POSTGRES_PORT="$postgres_port" \
|
||||
COORDINATOR_PORT="$coordinator_port" \
|
||||
@@ -46,7 +69,8 @@ compose() {
|
||||
docker compose -p "$project" \
|
||||
-f "$coordinator_dir/docker-compose.yml" \
|
||||
-f "$coordinator_dir/docker-compose.users.yml" \
|
||||
-f "$coordinator_dir/docker-compose.monitoring.yml" "$@"
|
||||
-f "$coordinator_dir/docker-compose.monitoring.yml" \
|
||||
$docs_compose_files "$@"
|
||||
}
|
||||
|
||||
stop_workers() {
|
||||
@@ -116,6 +140,7 @@ wait_for_workers() {
|
||||
}
|
||||
|
||||
start() {
|
||||
prepare_docs_override
|
||||
if ! [[ "$workers" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "DEMO_WORKERS must be a positive integer (got $workers)." >&2
|
||||
exit 2
|
||||
|
||||
Reference in New Issue
Block a user