Serve documentation from the operator UI

This commit is contained in:
Emil
2026-08-02 15:50:07 +03:00
parent 284aef5d6f
commit f20cc7fe00
72 changed files with 3509 additions and 698 deletions
+1 -1
View File
@@ -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
+3
View File
@@ -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"
+4
View File
@@ -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> &nbsp;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"))
}
}
+26 -1
View File
@@ -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