feat: self-service worker enrollment bound to a user account
Let a signed-in user turn their own machine into a worker without the shared token. The coordinator already binds a JWT-authenticated registration to owner_id as untrusted; this adds the missing pieces. userservice: long-lived worker keys (scimesh_wk_live_*, hash-at-rest) with create/list/revoke and a public /worker-tokens/exchange that trades a key for a short-lived JWT carrying the owner current role/verified. python worker: SCIMESH_WORKER_KEY + SCIMESH_USERSERVICE_URL; a token provider exchanges the key and refreshes the JWT proactively and on 401, so a long-running worker survives token expiry. Static bearer token path is unchanged. coordinator UI: an "add your machine" page that mints a key and shows a ready-to-run command, proxying key management to the userservice; the dashboard gains an owner-scoped "my machines" section. docs: how to run a worker from your account, plus the untrusted/quorum/ verified trust model.
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)
|
||||
api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, cfg.UserserviceURL, m, pool.Ping, cfg.PublicCoordinatorURL, cfg.PublicUserserviceURL)
|
||||
err = infra.RunServer(ctx, log, cfg.Addr, api.Handler(cfg.Token, cfg.UIToken))
|
||||
|
||||
// Shutdown order matters, and defers alone cannot express it (they run
|
||||
|
||||
@@ -64,3 +64,8 @@ services:
|
||||
environment:
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
USERSERVICE_URL: http://userservice:8081
|
||||
# Browser/host-facing URLs for the "add your machine" command. A user's
|
||||
# worker runs on the host, so it reaches the published ports on localhost,
|
||||
# not the in-cluster service names.
|
||||
PUBLIC_COORDINATOR_URL: http://localhost:${COORDINATOR_PORT:-8080}
|
||||
PUBLIC_USERSERVICE_URL: http://localhost:${USERSERVICE_PORT:-8081}
|
||||
|
||||
@@ -37,6 +37,13 @@ type Config struct {
|
||||
// login/registration (cookie session) instead of the static UI_AUTH_TOKEN
|
||||
// basic auth. Empty keeps the basic-auth UI.
|
||||
UserserviceURL string
|
||||
// Browser-facing base URLs used to render the "add your machine" command on
|
||||
// the UI. They must be reachable from a user's own machine, which is not
|
||||
// necessarily the in-cluster address the coordinator uses for UserserviceURL.
|
||||
// PublicCoordinatorURL empty lets the page fall back to its own origin;
|
||||
// PublicUserserviceURL empty falls back to UserserviceURL.
|
||||
PublicCoordinatorURL string
|
||||
PublicUserserviceURL string
|
||||
|
||||
// Minimum log level: debug, info, warn, error.
|
||||
LogLevel string
|
||||
@@ -92,23 +99,25 @@ func LoadConfig() (Config, error) {
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
// COORDINATOR_TOKEN is the contract name; WORKER_AUTH_TOKEN is the
|
||||
// former name, still honoured so existing .env files keep working.
|
||||
Token: getEnv("COORDINATOR_TOKEN", os.Getenv("WORKER_AUTH_TOKEN")),
|
||||
UIToken: os.Getenv("UI_AUTH_TOKEN"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
UserserviceURL: os.Getenv("USERSERVICE_URL"),
|
||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||
LogFile: os.Getenv("LOG_FILE"),
|
||||
StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"),
|
||||
MaxUploadBytes: 1 << 30, // 1 GiB
|
||||
DBMaxConns: 10,
|
||||
DBConnectTimeout: 30 * time.Second,
|
||||
RequestTimeout: 15 * time.Second,
|
||||
HeartbeatInterval: 15 * time.Second,
|
||||
LeaseDuration: 2 * time.Minute,
|
||||
DefaultMaxAttempts: 3,
|
||||
QuorumSize: 2,
|
||||
ReaperInterval: 30 * time.Second,
|
||||
WorkerOfflineAfter: 1 * time.Minute,
|
||||
Token: getEnv("COORDINATOR_TOKEN", os.Getenv("WORKER_AUTH_TOKEN")),
|
||||
UIToken: os.Getenv("UI_AUTH_TOKEN"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
UserserviceURL: os.Getenv("USERSERVICE_URL"),
|
||||
PublicCoordinatorURL: os.Getenv("PUBLIC_COORDINATOR_URL"),
|
||||
PublicUserserviceURL: getEnv("PUBLIC_USERSERVICE_URL", os.Getenv("USERSERVICE_URL")),
|
||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||
LogFile: os.Getenv("LOG_FILE"),
|
||||
StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"),
|
||||
MaxUploadBytes: 1 << 30, // 1 GiB
|
||||
DBMaxConns: 10,
|
||||
DBConnectTimeout: 30 * time.Second,
|
||||
RequestTimeout: 15 * time.Second,
|
||||
HeartbeatInterval: 15 * time.Second,
|
||||
LeaseDuration: 2 * time.Minute,
|
||||
DefaultMaxAttempts: 3,
|
||||
QuorumSize: 2,
|
||||
ReaperInterval: 30 * time.Second,
|
||||
WorkerOfflineAfter: 1 * time.Minute,
|
||||
}
|
||||
|
||||
if cfg.DatabaseURL == "" {
|
||||
|
||||
@@ -87,16 +87,44 @@ func (r *UIReadRepo) ListWorkers(_ context.Context, limit int) ([]domain.Worker,
|
||||
copy.Capabilities = append([]string(nil), worker.Capabilities...)
|
||||
out = append(out, copy)
|
||||
}
|
||||
sortWorkers(out)
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *UIReadRepo) ListWorkersByOwner(_ context.Context, owner uuid.UUID, limit int) ([]domain.Worker, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
r.workers.mu.Lock()
|
||||
defer r.workers.mu.Unlock()
|
||||
out := []domain.Worker{}
|
||||
for _, worker := range r.workers.workers {
|
||||
if worker.OwnerID == nil || *worker.OwnerID != owner {
|
||||
continue
|
||||
}
|
||||
copy := *worker
|
||||
copy.Capabilities = append([]string(nil), worker.Capabilities...)
|
||||
out = append(out, copy)
|
||||
}
|
||||
sortWorkers(out)
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// sortWorkers orders workers most-recently-seen first, breaking ties on id so
|
||||
// the order is deterministic across calls.
|
||||
func sortWorkers(out []domain.Worker) {
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].LastHeartbeatAt.Equal(out[j].LastHeartbeatAt) {
|
||||
return out[i].ID.String() > out[j].ID.String()
|
||||
}
|
||||
return out[i].LastHeartbeatAt.After(out[j].LastHeartbeatAt)
|
||||
})
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (r *UIReadRepo) ListArtifactsByJob(_ context.Context, jobID uuid.UUID) ([]domain.Artifact, error) {
|
||||
r.artifacts.mu.Lock()
|
||||
|
||||
@@ -128,6 +128,32 @@ func (r *UIReadRepo) ListWorkers(ctx context.Context, limit int) ([]domain.Worke
|
||||
return workers, rows.Err()
|
||||
}
|
||||
|
||||
func (r *UIReadRepo) ListWorkersByOwner(ctx context.Context, owner uuid.UUID, limit int) ([]domain.Worker, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
sql, args, err := psql.Select(workerColumns...).From("workers").
|
||||
Where(sq.Eq{"owner_id": owner}).
|
||||
OrderBy("last_heartbeat_at DESC", "id DESC").Limit(uint64(limit)).ToSql()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := conn(ctx, r.pool).Query(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list workers by owner: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
workers := make([]domain.Worker, 0)
|
||||
for rows.Next() {
|
||||
worker, err := scanWorker(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
workers = append(workers, *worker)
|
||||
}
|
||||
return workers, rows.Err()
|
||||
}
|
||||
|
||||
func (r *UIReadRepo) ListArtifactsByJob(ctx context.Context, jobID uuid.UUID) ([]domain.Artifact, error) {
|
||||
sql, args, err := psql.Select(artifactColumns...).From("artifacts").Where(sq.Eq{"job_id": jobID}).OrderBy("created_at ASC", "id ASC").ToSql()
|
||||
if err != nil {
|
||||
|
||||
@@ -49,6 +49,11 @@ type Server struct {
|
||||
// userserviceURL is the base URL the UI proxies login/registration to. Empty
|
||||
// keeps the static basic-auth UI.
|
||||
userserviceURL string
|
||||
// publicCoordinatorURL / publicUserserviceURL are the browser-facing URLs
|
||||
// rendered into the worker-enrollment command. Either may be empty; the
|
||||
// template falls back (own origin / userserviceURL respectively).
|
||||
publicCoordinatorURL string
|
||||
publicUserserviceURL string
|
||||
// httpClient makes the login/register calls to the userservice.
|
||||
httpClient *http.Client
|
||||
// metrics holds the Prometheus registry and HTTP instrumentation.
|
||||
@@ -59,21 +64,33 @@ type Server struct {
|
||||
}
|
||||
|
||||
func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval time.Duration,
|
||||
maxUploadBytes int64, jwtSecret, userserviceURL string, m *metrics.Metrics, ready func(context.Context) error) *Server {
|
||||
maxUploadBytes int64, jwtSecret, userserviceURL string, m *metrics.Metrics, ready func(context.Context) error,
|
||||
publicURLs ...string) *Server {
|
||||
if m == nil {
|
||||
m = metrics.New()
|
||||
}
|
||||
// publicURLs is variadic so existing callers/tests need no change: [0] is the
|
||||
// public coordinator URL, [1] the public userservice URL; both optional.
|
||||
var publicCoordinatorURL, publicUserserviceURL string
|
||||
if len(publicURLs) > 0 {
|
||||
publicCoordinatorURL = strings.TrimRight(publicURLs[0], "/")
|
||||
}
|
||||
if len(publicURLs) > 1 {
|
||||
publicUserserviceURL = strings.TrimRight(publicURLs[1], "/")
|
||||
}
|
||||
return &Server{
|
||||
uc: uc,
|
||||
log: log,
|
||||
requestTimeout: requestTimeout,
|
||||
heartbeatInterval: heartbeatInterval,
|
||||
maxUploadBytes: maxUploadBytes,
|
||||
verifier: tokenpkg.NewVerifier(jwtSecret),
|
||||
userserviceURL: strings.TrimRight(userserviceURL, "/"),
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
metrics: m,
|
||||
ready: ready,
|
||||
uc: uc,
|
||||
log: log,
|
||||
requestTimeout: requestTimeout,
|
||||
heartbeatInterval: heartbeatInterval,
|
||||
maxUploadBytes: maxUploadBytes,
|
||||
verifier: tokenpkg.NewVerifier(jwtSecret),
|
||||
userserviceURL: strings.TrimRight(userserviceURL, "/"),
|
||||
publicCoordinatorURL: publicCoordinatorURL,
|
||||
publicUserserviceURL: publicUserserviceURL,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
metrics: m,
|
||||
ready: ready,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +156,14 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler {
|
||||
ui.Handle(rt.pattern, gate(rt.handler))
|
||||
}
|
||||
ui.Handle("GET /ui/profile", gate(http.HandlerFunc(s.handleUIProfile)))
|
||||
// Worker enrollment: a user creates/lists/revokes their own worker keys
|
||||
// and copies a ready-to-run command. Session-only — it proxies to the
|
||||
// userservice with the caller's token, so it has no meaning under basic
|
||||
// auth (which has no userservice).
|
||||
ui.Handle("GET /ui/workers/new", gate(http.HandlerFunc(s.handleUIAddWorker)))
|
||||
ui.Handle("GET /ui/api/worker-keys", gate(http.HandlerFunc(s.handleUIWorkerKeysList)))
|
||||
ui.Handle("POST /ui/api/worker-keys", gate(http.HandlerFunc(s.handleUIWorkerKeyCreate)))
|
||||
ui.Handle("POST /ui/api/worker-keys/{id}/revoke", gate(http.HandlerFunc(s.handleUIWorkerKeyRevoke)))
|
||||
// Admin panel: session + admin role.
|
||||
ui.Handle("GET /ui/admin", chain(http.HandlerFunc(s.handleUIAdmin), gate, requireAdmin))
|
||||
ui.Handle("POST /ui/admin/user-action", chain(http.HandlerFunc(s.handleUIAdminUserAction), gate, requireAdmin))
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
{{define "add-worker.html"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Add your machine · 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 10% -8%,#183f77 0,transparent 32rem),#08111f}.page{max-width:980px;margin:auto;padding:28px 22px 64px}a{color:#94bdff}.back{text-decoration:none}.eyebrow{margin:28px 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,3.25rem);letter-spacing:-.055em}.lead{max-width:720px;margin:10px 0 0;color:#aabed9;font-size:1.06rem}.layout{display:grid;grid-template-columns:1.45fr .8fr;gap:15px;margin-top:28px}.card,.aside,.notice{border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021}.card{padding:22px}.aside,.notice{padding:18px}.aside h2,.notice h2{margin:0;color:#f1f6ff;font-size:1rem}.aside p,.notice p{color:#9fb3cf}.aside ol{margin:13px 0 0;padding-left:20px;color:#aebfda}.aside li{margin:10px 0}label{display:block;margin:18px 0 5px;color:#eaf2ff;font-weight:750}input{width:100%;border:1px solid #42617f;border-radius:9px;padding:10px 11px;background:#0a1626;color:#e5efff;font:inherit}input:focus{outline:2px solid #5d97f5;outline-offset:1px}.hint{margin:5px 0;color:#92a9c6;font-size:.87rem}.button{display:inline-flex;margin-top:16px;border:0;border-radius:10px;padding:11px 15px;background:#67e3b8;color:#062018;font:inherit;font-weight:850;cursor:pointer}.button.secondary{background:#23344d;color:#dce8ff}.button:disabled{opacity:.6;cursor:wait}.command{margin-top:18px;border:1px solid #2c8c70;border-radius:11px;padding:14px;background:#0c2b2a;color:#a8f1d0}.command strong{color:#e6fff4}.command pre{margin:10px 0 0;padding:12px;overflow-x:auto;border-radius:8px;background:#061a19;color:#c8ffe8;font:.82rem/1.5 ui-monospace,SFMono-Regular,monospace;white-space:pre;word-break:normal}.keys{margin-top:14px;display:grid;gap:9px}.key{display:flex;align-items:center;justify-content:space-between;gap:12px;border:1px solid #294662;border-radius:11px;padding:12px 14px;background:#0a1626}.key .kn{color:#f3f7ff;font-weight:700}.key .kp{margin-top:3px;color:#8196b3;font-family:ui-monospace,SFMono-Regular,monospace;font-size:.76rem}.key .kd{color:#8fa6c3;font-size:.8rem}.revoke{border:1px solid #6a2a3a;border-radius:8px;padding:7px 11px;background:#2a1420;color:#ff9bad;font:inherit;font-weight:700;cursor:pointer}.empty{padding:20px;border:1px dashed #35516f;border-radius:12px;color:#9ab0cb;text-align:center}.error{margin:12px 0 0;color:#ffacba}.warn{color:#ffd08a}.hidden{display:none}.cap{display:inline-block;margin:4px 5px 0 0;border:1px solid #375978;border-radius:5px;padding:2px 6px;color:#b5d3f5;font:.76rem ui-monospace,SFMono-Regular,monospace}@media(max-width:720px){.layout{grid-template-columns:1fr}.page{padding:22px 14px}}
|
||||
</style>
|
||||
</head>
|
||||
<body data-coordinator="{{.CoordinatorURL}}" data-userservice="{{.UserserviceURL}}">
|
||||
<main class="page">
|
||||
<a class="back" href="/ui">← Back to control room</a><p class="eyebrow">Contribute compute</p><h1>Turn this computer into a worker</h1><p class="lead">Create a key, install the worker, and run one command. The worker binds to your account and pulls tasks whenever it is online.</p>
|
||||
<div class="layout">
|
||||
<section class="card">
|
||||
<h2 style="margin:0 0 4px;color:#f1f6ff">Your worker keys</h2>
|
||||
<p class="hint">A key is long-lived and does not expire like a login. The worker trades it for short-lived tokens automatically. Revoke a key to stop its machines.</p>
|
||||
<form id="create" novalidate>
|
||||
<label for="key-name">Name this machine <small>(optional)</small></label>
|
||||
<input id="key-name" name="name" maxlength="100" placeholder="e.g. home-desktop" autocomplete="off">
|
||||
<button class="button" id="create-btn" type="submit">Create key →</button>
|
||||
<p id="error" class="error" role="alert"></p>
|
||||
</form>
|
||||
<div id="command" class="command hidden"></div>
|
||||
<div id="keys" class="keys"></div>
|
||||
</section>
|
||||
<aside class="aside">
|
||||
<h2>Set it up</h2>
|
||||
<ol>
|
||||
<li><strong>Create a key</strong><br>Use the form; copy the command it generates.</li>
|
||||
<li><strong>Paste it in a terminal</strong><br>The command clones the project, sets up a Python environment, installs the worker, and starts it. The machine then appears under <a href="/ui">My machines</a>.</li>
|
||||
</ol>
|
||||
<h2 style="margin-top:24px">Will my results count?</h2>
|
||||
<p>Your worker is <strong>untrusted</strong> by default: its results are cross-checked and accepted once a second independent worker computes the same answer (quorum), or once an admin marks your account <strong>verified</strong> — then your workers are trusted and results count immediately.</p>
|
||||
<p><span class="cap">similarity-search</span> is the only workload a volunteer worker runs today.</p>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const coord=(document.body.dataset.coordinator||location.origin).replace(/\/+$/,'');
|
||||
const users=(document.body.dataset.userservice||'').replace(/\/+$/,'');
|
||||
const keysBox=document.querySelector('#keys'),cmdBox=document.querySelector('#command'),form=document.querySelector('#create'),nameInput=document.querySelector('#key-name'),createBtn=document.querySelector('#create-btn'),error=document.querySelector('#error');
|
||||
const node=(tag,text,cls)=>{const n=document.createElement(tag);if(text!==undefined)n.textContent=text;if(cls)n.className=cls;return n};
|
||||
const buildCommand=key=>['git clone https://github.com/emil28092005/SciMesh.git','cd SciMesh','python -m venv .venv','source .venv/bin/activate','pip install -e .','','SCIMESH_COORDINATOR_URL='+coord+' \\','SCIMESH_USERSERVICE_URL='+(users||'<your userservice URL>')+' \\','SCIMESH_WORKER_KEY='+key+' \\','scimesh-worker --worker-name my-machine'].join('\n');
|
||||
const showCommand=key=>{cmdBox.replaceChildren();cmdBox.append(node('strong','Key created — copy it now, it is shown only once.'));const pre=node('pre',buildCommand(key));cmdBox.append(pre);const copy=node('button','Copy command','button secondary');copy.type='button';copy.addEventListener('click',()=>{navigator.clipboard&&navigator.clipboard.writeText(buildCommand(key)).then(()=>{copy.textContent='Copied ✓'},()=>{copy.textContent='Press Ctrl+C to copy'})});cmdBox.append(document.createElement('br'),copy);if(!users){cmdBox.append(node('p','Set SCIMESH_USERSERVICE_URL to a userservice URL your machine can reach.','warn'))}cmdBox.classList.remove('hidden')};
|
||||
const revoke=async id=>{const r=await fetch('/ui/api/worker-keys/'+encodeURIComponent(id)+'/revoke',{method:'POST'});if(r.status===204||r.ok){loadKeys()}else{error.textContent='Could not revoke the key.'}};
|
||||
const renderKeys=keys=>{keysBox.replaceChildren();if(!keys.length){keysBox.append(node('div','No keys yet. Create one above to connect a machine.','empty'));return}for(const k of keys){const row=node('div',undefined,'key'),left=node('div');left.append(node('div',k.name||'unnamed','kn'),node('div',k.prefix+'…','kp'),node('div','Created '+new Date(k.created_at).toLocaleString()+(k.last_used_at?' · last used '+new Date(k.last_used_at).toLocaleString():' · never used'),'kd'));const btn=node('button','Revoke','revoke');btn.type='button';btn.addEventListener('click',()=>revoke(k.id));row.append(left,btn);keysBox.append(row)}};
|
||||
const loadKeys=async()=>{try{const r=await fetch('/ui/api/worker-keys',{headers:{Accept:'application/json'}});if(!r.ok)throw Error();const data=await r.json();renderKeys(data.worker_keys||[])}catch(_){keysBox.replaceChildren(node('div','Could not load your keys.','empty'))}};
|
||||
form.addEventListener('submit',async event=>{event.preventDefault();error.textContent='';createBtn.disabled=true;try{const r=await fetch('/ui/api/worker-keys',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:nameInput.value.trim()})}),data=await r.json().catch(()=>({}));if(!r.ok)throw Error(data.error||'Could not create the key.');showCommand(data.key);nameInput.value='';loadKeys()}catch(err){error.textContent=err.message}finally{createBtn.disabled=false}});
|
||||
loadKeys();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -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}}<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/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>
|
||||
@@ -23,6 +23,7 @@
|
||||
</section>
|
||||
|
||||
<section><div class="section-head"><h2>Recent computations</h2><p id="job-count">{{len .Jobs}} shown · newest first</p></div><div id="jobs" class="jobs">{{range .Jobs}}<a class="job" href="/ui/jobs/{{.ID}}"><div><div class="job-name">{{workloadLabel .Workload}}</div><div class="job-id">{{.ID}}</div></div><div><span class="badge badge-{{statusClass .Status}}">{{statusLabel .Status}}</span><div class="job-id">{{statusHint .Status}}</div></div><div><div class="numbers"><b>{{.Completed}}</b> / {{.Total}} shards complete{{if gt .Failed 0}} · <span class="offline">{{.Failed}} failed</span>{{end}}</div><div class="bar"><span style="width:{{progressPercent .Completed .Failed .Cancelled .Total}}%"></span></div></div><span class="arrow" aria-hidden="true">→</span></a>{{else}}<div class="empty"><strong>No computations yet.</strong><br>Start a small similarity search, then keep one or more workers running to watch this dashboard come alive.</div>{{end}}</div></section>
|
||||
{{if and .Session (ne .Session.Role "admin")}}<section><div class="section-head"><h2>My machines</h2><p>Workers you registered. <a href="/ui/workers/new" style="color:#79aaff">Add your machine →</a></p></div><div id="my-workers" class="workers">{{range .MyWorkers}}<article class="worker"><div class="worker-head"><strong>{{.Name}}</strong><span class="badge badge-{{workerStatusClass .Status}}">{{workerStatusLabel .Status}}</span></div><small>{{.ID}}</small><p>{{range .Capabilities}}<span class="cap">{{.}}</span>{{end}}</p><p>Last signal · {{time .LastHeartbeatAt}}</p></article>{{else}}<div class="empty"><strong>No machine of yours is connected.</strong><br><a href="/ui/workers/new" style="color:#79aaff">Turn this computer into a worker →</a></div>{{end}}</div></section>{{end}}
|
||||
<section><div class="section-head"><h2>Worker fleet</h2><p>Workers register themselves; this page never controls their processes.</p></div><div id="workers" class="workers">{{range .Workers}}<article class="worker"><div class="worker-head"><strong>{{.Name}}</strong><span class="badge badge-{{workerStatusClass .Status}}">{{workerStatusLabel .Status}}</span></div><small>{{.ID}}</small><p>{{range .Capabilities}}<span class="cap">{{.}}</span>{{end}}</p><p>Last signal · {{time .LastHeartbeatAt}}</p></article>{{else}}<div class="empty"><strong>No worker is registered.</strong><br>Start <code>scimesh-worker</code> in another terminal, then return here.</div>{{end}}</div></section>
|
||||
</main>
|
||||
<script>
|
||||
@@ -30,8 +31,10 @@
|
||||
const pct=j=>j.total?Math.min(100,Math.floor((j.completed+j.failed+j.cancelled)*100/j.total)):0;
|
||||
const node=(tag,text,cls)=>{const n=document.createElement(tag);if(text!==undefined)n.textContent=text;if(cls)n.className=cls;return n};
|
||||
const renderJobs=jobs=>{const box=document.querySelector('#jobs');box.replaceChildren();if(!jobs.length){const empty=node('div',undefined,'empty');empty.append(node('strong','No computations yet.'),document.createElement('br'),document.createTextNode('Start a small similarity search, then keep one or more workers running to watch this dashboard come alive.'));box.append(empty);return}for(const job of jobs){const info=statusInfo[job.status]||[job.status,'waiting'],link=node('a',undefined,'job');link.href='/ui/jobs/'+encodeURIComponent(job.id);const intro=node('div');intro.append(node('div',job.workload==='similarity-search'?'Molecule similarity search':job.workload,'job-name'),node('div',job.id,'job-id'));const state=node('div');state.append(node('span',info[0],'badge badge-'+info[1]),node('div',job.status==='reducing'?'Every shard is complete; coordinator is ranking the global top-k.':'Live coordinator state','job-id'));const progress=node('div'),numbers=node('div',undefined,'numbers');numbers.append(node('b',String(job.completed)),document.createTextNode(' / '+job.total+' shards complete'));if(job.failed){numbers.append(document.createTextNode(' · '),node('span',job.failed+' failed','offline'))}const bar=node('div',undefined,'bar'),fill=node('span');fill.style.width=pct(job)+'%';bar.append(fill);progress.append(numbers,bar);link.append(intro,state,progress,node('span','→','arrow'));box.append(link)}};
|
||||
const renderWorkers=workers=>{const box=document.querySelector('#workers');box.replaceChildren();if(!workers.length){const empty=node('div',undefined,'empty');empty.append(node('strong','No worker is registered.'),document.createElement('br'),document.createTextNode('Start scimesh-worker in another terminal, then return here.'));box.append(empty);return}for(const worker of workers){const card=node('article',undefined,'worker'),head=node('div',undefined,'worker-head'),left=node('div'),workerInfo=worker.status==='online'?['Available','success']:worker.status==='busy'?['Busy','active']:['Offline','waiting'];left.append(node('strong',worker.name),node('small',worker.id));head.append(left,node('span',workerInfo[0],'badge badge-'+workerInfo[1]));const caps=node('p');for(const capability of worker.capabilities||[])caps.append(node('span',capability,'cap'));card.append(head,caps,node('p','Last signal · '+new Date(worker.last_heartbeat_at).toLocaleString()));box.append(card)}};
|
||||
let timer;const refresh=async()=>{try{const response=await fetch('/ui/api/overview',{headers:{Accept:'application/json'}});if(!response.ok)throw Error();const view=await response.json();document.querySelector('#active-jobs').textContent=view.active_jobs;document.querySelector('#online-workers').textContent=view.online_workers;document.querySelector('#finished-jobs').textContent=view.finished_jobs;document.querySelector('#job-count').textContent=view.jobs.length+' shown · newest first';renderJobs(view.jobs);renderWorkers(view.workers);document.querySelector('#refresh-state').textContent='Live overview · updated just now'}catch(_){document.querySelector('#refresh-state').textContent='Connection interrupted · trying again automatically'}};
|
||||
const workerCard=worker=>{const card=node('article',undefined,'worker'),head=node('div',undefined,'worker-head'),left=node('div'),workerInfo=worker.status==='online'?['Available','success']:worker.status==='busy'?['Busy','active']:['Offline','waiting'];left.append(node('strong',worker.name),node('small',worker.id));head.append(left,node('span',workerInfo[0],'badge badge-'+workerInfo[1]));const caps=node('p');for(const capability of worker.capabilities||[])caps.append(node('span',capability,'cap'));card.append(head,caps,node('p','Last signal · '+new Date(worker.last_heartbeat_at).toLocaleString()));return card};
|
||||
const renderWorkers=workers=>{const box=document.querySelector('#workers');box.replaceChildren();if(!workers.length){const empty=node('div',undefined,'empty');empty.append(node('strong','No worker is registered.'),document.createElement('br'),document.createTextNode('Start scimesh-worker in another terminal, then return here.'));box.append(empty);return}for(const worker of workers)box.append(workerCard(worker))};
|
||||
const renderMyWorkers=workers=>{const box=document.querySelector('#my-workers');if(!box)return;box.replaceChildren();if(!workers.length){const empty=node('div',undefined,'empty'),link=node('a','Turn this computer into a worker →');link.href='/ui/workers/new';link.style.color='#79aaff';empty.append(node('strong','No machine of yours is connected.'),document.createElement('br'),link);box.append(empty);return}for(const worker of workers)box.append(workerCard(worker))};
|
||||
let timer;const refresh=async()=>{try{const response=await fetch('/ui/api/overview',{headers:{Accept:'application/json'}});if(!response.ok)throw Error();const view=await response.json();document.querySelector('#active-jobs').textContent=view.active_jobs;document.querySelector('#online-workers').textContent=view.online_workers;document.querySelector('#finished-jobs').textContent=view.finished_jobs;document.querySelector('#job-count').textContent=view.jobs.length+' shown · newest first';renderJobs(view.jobs);renderWorkers(view.workers);renderMyWorkers(view.my_workers||[]);document.querySelector('#refresh-state').textContent='Live overview · updated just now'}catch(_){document.querySelector('#refresh-state').textContent='Connection interrupted · trying again automatically'}};
|
||||
const start=()=>{if(!timer&&!document.hidden)timer=setInterval(refresh,2000)};document.addEventListener('visibilitychange',()=>{if(document.hidden&&timer){clearInterval(timer);timer=undefined}else start()});start();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||
)
|
||||
|
||||
// handleUIAddWorker renders the "add your machine" page: instructions, the
|
||||
// user's existing worker keys, and a ready-to-run command carrying a freshly
|
||||
// minted key. All key operations happen client-side against the JSON endpoints
|
||||
// below; this handler only supplies the browser-facing URLs.
|
||||
func (s *Server) handleUIAddWorker(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]any{
|
||||
"CoordinatorURL": s.publicCoordinatorURL,
|
||||
"UserserviceURL": s.publicUserserviceURL,
|
||||
}
|
||||
if req, ok := authctx.From(r.Context()); ok {
|
||||
data["Session"] = &usecase.SessionView{Role: req.Role, Verified: req.Verified}
|
||||
}
|
||||
s.renderUI(w, "add-worker.html", data)
|
||||
}
|
||||
|
||||
// handleUIWorkerKeysList proxies the caller's live worker keys from the
|
||||
// userservice, forwarding their session token.
|
||||
func (s *Server) handleUIWorkerKeysList(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not signed in"})
|
||||
return
|
||||
}
|
||||
status, body, err := s.callUserserviceAuthed(r.Context(), http.MethodGet, "/worker-keys", c.Value)
|
||||
if err != nil {
|
||||
s.log.Error("worker-keys list proxy", "err", err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "userservice unavailable"})
|
||||
return
|
||||
}
|
||||
proxyJSON(w, status, body)
|
||||
}
|
||||
|
||||
// handleUIWorkerKeyCreate mints a new worker key via the userservice and returns
|
||||
// its response — including the one-time plaintext key — straight to the browser.
|
||||
func (s *Server) handleUIWorkerKeyCreate(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not signed in"})
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<12))
|
||||
if len(body) == 0 {
|
||||
body = []byte("{}")
|
||||
}
|
||||
status, respBody, err := s.callUserserviceAuthedBody(r.Context(), http.MethodPost, "/worker-keys", c.Value, body)
|
||||
if err != nil {
|
||||
s.log.Error("worker-key create proxy", "err", err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "userservice unavailable"})
|
||||
return
|
||||
}
|
||||
proxyJSON(w, status, respBody)
|
||||
}
|
||||
|
||||
// handleUIWorkerKeyRevoke retires one of the caller's keys via the userservice.
|
||||
// The id is validated as a UUID so the proxied path can never be attacker-shaped.
|
||||
func (s *Server) handleUIWorkerKeyRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid worker key id"})
|
||||
return
|
||||
}
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not signed in"})
|
||||
return
|
||||
}
|
||||
status, _, err := s.callUserserviceAuthed(r.Context(), http.MethodDelete, "/worker-keys/"+id, c.Value)
|
||||
if err != nil {
|
||||
s.log.Error("worker-key revoke proxy", "err", err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "userservice unavailable"})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
}
|
||||
|
||||
// proxyJSON forwards a userservice JSON response verbatim, preserving its status.
|
||||
func proxyJSON(w http.ResponseWriter, status int, body []byte) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// callUserserviceAuthedBody is callUserserviceAuthed with a JSON request body,
|
||||
// used for the create call. Kept separate so the bodyless admin/profile callers
|
||||
// stay unchanged.
|
||||
func (s *Server) callUserserviceAuthedBody(ctx context.Context, method, path, bearer string, body []byte) (int, []byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, s.userserviceURL+path, bytes.NewReader(body)) //nolint:gosec // G704: path is a fixed literal, host is config
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.httpClient.Do(req) //nolint:gosec // G704: see above
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return resp.StatusCode, respBody, nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWorkerKeyCreateProxiesWithBody(t *testing.T) {
|
||||
var gotAuth, gotPath, gotMethod, gotBody string
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth, gotPath, gotMethod = r.Header.Get("Authorization"), r.URL.Path, r.Method
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(b)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":"11111111-1111-1111-1111-111111111111","name":"box","prefix":"scimesh_wk_live_ab","created_at":"2026-07-26T00:00:00Z","key":"scimesh_wk_live_secret"}`))
|
||||
}))
|
||||
defer stub.Close()
|
||||
s := newLoginServer(stub)
|
||||
|
||||
req := newReq(http.MethodPost, "/ui/api/worker-keys", strings.NewReader(`{"name":"box"}`))
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "my.jwt"})
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUIWorkerKeyCreate(rec, req)
|
||||
|
||||
if gotAuth != "Bearer my.jwt" || gotPath != "/worker-keys" || gotMethod != http.MethodPost {
|
||||
t.Fatalf("proxy: auth=%q path=%q method=%q", gotAuth, gotPath, gotMethod)
|
||||
}
|
||||
if !strings.Contains(gotBody, `"name":"box"`) {
|
||||
t.Errorf("request body not forwarded: %q", gotBody)
|
||||
}
|
||||
if rec.Code != http.StatusCreated || !strings.Contains(rec.Body.String(), "scimesh_wk_live_secret") {
|
||||
t.Errorf("response not passed through: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerKeysListProxies(t *testing.T) {
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/worker-keys" {
|
||||
t.Errorf("unexpected upstream call %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"worker_keys":[{"id":"1","name":"box","prefix":"scimesh_wk_live_ab","created_at":"2026-07-26T00:00:00Z"}]}`))
|
||||
}))
|
||||
defer stub.Close()
|
||||
s := newLoginServer(stub)
|
||||
|
||||
req := newReq(http.MethodGet, "/ui/api/worker-keys", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "my.jwt"})
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUIWorkerKeysList(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "worker_keys") {
|
||||
t.Errorf("list not passed through: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerKeyRevokeProxiesDelete(t *testing.T) {
|
||||
const id = "22222222-2222-2222-2222-222222222222"
|
||||
var gotPath, gotMethod string
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath, gotMethod = r.URL.Path, r.Method
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer stub.Close()
|
||||
s := newLoginServer(stub)
|
||||
|
||||
req := newReq(http.MethodPost, "/ui/api/worker-keys/"+id+"/revoke", nil)
|
||||
req.SetPathValue("id", id)
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "my.jwt"})
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUIWorkerKeyRevoke(rec, req)
|
||||
|
||||
if gotMethod != http.MethodDelete || gotPath != "/worker-keys/"+id {
|
||||
t.Fatalf("proxy: method=%q path=%q", gotMethod, gotPath)
|
||||
}
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Errorf("revoke status = %d, want 204", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerKeyRevokeRejectsBadID(t *testing.T) {
|
||||
s := newLoginServer(httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Fatal("must not call userservice for an invalid id")
|
||||
})))
|
||||
req := newReq(http.MethodPost, "/ui/api/worker-keys/not-a-uuid/revoke", nil)
|
||||
req.SetPathValue("id", "not-a-uuid")
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "my.jwt"})
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUIWorkerKeyRevoke(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("bad id: got %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerKeysRequireSession(t *testing.T) {
|
||||
s := newLoginServer(httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Fatal("must not call userservice without a session cookie")
|
||||
})))
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUIWorkerKeysList(rec, newReq(http.MethodGet, "/ui/api/worker-keys", nil))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("no cookie: got %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ type UIReadRepository interface {
|
||||
ListTasksByJob(ctx context.Context, jobID uuid.UUID) ([]domain.Task, error)
|
||||
ListTasksByJobs(ctx context.Context, jobIDs []uuid.UUID) (map[uuid.UUID][]domain.Task, error)
|
||||
ListWorkers(ctx context.Context, limit int) ([]domain.Worker, error)
|
||||
// ListWorkersByOwner returns the most recent workers registered by one user,
|
||||
// for the "my machines" section of the dashboard.
|
||||
ListWorkersByOwner(ctx context.Context, owner uuid.UUID, limit int) ([]domain.Worker, error)
|
||||
ListArtifactsByJob(ctx context.Context, jobID uuid.UUID) ([]domain.Artifact, error)
|
||||
}
|
||||
|
||||
@@ -83,8 +86,11 @@ type WorkerCard struct {
|
||||
}
|
||||
|
||||
type DashboardView struct {
|
||||
Jobs []JobCard `json:"jobs"`
|
||||
Workers []WorkerCard `json:"workers"`
|
||||
Jobs []JobCard `json:"jobs"`
|
||||
Workers []WorkerCard `json:"workers"`
|
||||
// MyWorkers is the signed-in user's own registered workers. Empty for an
|
||||
// admin or a basic-auth operator, who instead see the whole fleet in Workers.
|
||||
MyWorkers []WorkerCard `json:"my_workers"`
|
||||
ActiveJobs int `json:"active_jobs"`
|
||||
FinishedJobs int `json:"finished_jobs"`
|
||||
OnlineWorkers int `json:"online_workers"`
|
||||
@@ -152,15 +158,37 @@ func (d *Dashboard) Overview(ctx context.Context, limit int) (DashboardView, err
|
||||
}
|
||||
}
|
||||
for _, worker := range workers {
|
||||
out.Workers = append(out.Workers, WorkerCard{ID: worker.ID.String(), Name: worker.Name, Status: string(worker.Status), Capabilities: worker.Capabilities, LastHeartbeatAt: worker.LastHeartbeatAt})
|
||||
out.Workers = append(out.Workers, workerCard(worker))
|
||||
if worker.Status == domain.WorkerOnline || worker.Status == domain.WorkerBusy {
|
||||
out.OnlineWorkers++
|
||||
}
|
||||
}
|
||||
// A plain user also gets a dedicated "my machines" list scoped to their own
|
||||
// registrations; an admin/operator sees only the fleet above.
|
||||
if owner := uiOwnerFilter(ctx); owner != nil {
|
||||
mine, err := d.read.ListWorkersByOwner(ctx, *owner, limit)
|
||||
if err != nil {
|
||||
return DashboardView{}, err
|
||||
}
|
||||
out.MyWorkers = make([]WorkerCard, 0, len(mine))
|
||||
for _, worker := range mine {
|
||||
out.MyWorkers = append(out.MyWorkers, workerCard(worker))
|
||||
}
|
||||
}
|
||||
out.Session = sessionViewFrom(ctx)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func workerCard(w domain.Worker) WorkerCard {
|
||||
return WorkerCard{
|
||||
ID: w.ID.String(),
|
||||
Name: w.Name,
|
||||
Status: string(w.Status),
|
||||
Capabilities: w.Capabilities,
|
||||
LastHeartbeatAt: w.LastHeartbeatAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dashboard) JobDetail(ctx context.Context, jobID uuid.UUID) (JobDetailView, error) {
|
||||
job, err := d.read.GetJob(ctx, jobID)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package usecase_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/memstore"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||
)
|
||||
|
||||
func newDashboardWithWorkers() (*usecase.Dashboard, *memstore.WorkerRepo) {
|
||||
jobs := memstore.NewJobRepo()
|
||||
tasks := memstore.NewTaskRepo()
|
||||
workers := memstore.NewWorkerRepo()
|
||||
artifacts := memstore.NewArtifactRepo()
|
||||
return usecase.NewDashboard(memstore.NewUIReadRepo(jobs, tasks, workers, artifacts)), workers
|
||||
}
|
||||
|
||||
func seedWorker(t *testing.T, workers *memstore.WorkerRepo, owner *uuid.UUID, name string) {
|
||||
t.Helper()
|
||||
w := &domain.Worker{
|
||||
ID: uuid.New(),
|
||||
Name: name,
|
||||
Capabilities: []string{"similarity-search"},
|
||||
Status: domain.WorkerOnline,
|
||||
OwnerID: owner,
|
||||
LastHeartbeatAt: time.Now().UTC(),
|
||||
}
|
||||
if err := workers.Insert(context.Background(), w); err != nil {
|
||||
t.Fatalf("insert worker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverviewSplitsMyWorkers(t *testing.T) {
|
||||
dash, workers := newDashboardWithWorkers()
|
||||
alice, bob := uuid.New(), uuid.New()
|
||||
seedWorker(t, workers, &alice, "alice-box")
|
||||
seedWorker(t, workers, &bob, "bob-box")
|
||||
seedWorker(t, workers, nil, "lab-shared") // owner-less shared-token worker
|
||||
|
||||
// A plain user sees the whole fleet, but MyWorkers holds only their own.
|
||||
v, err := dash.Overview(userCtx(alice, "user"), 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(v.Workers) != 3 {
|
||||
t.Errorf("fleet shows %d workers, want 3", len(v.Workers))
|
||||
}
|
||||
if len(v.MyWorkers) != 1 || v.MyWorkers[0].Name != "alice-box" {
|
||||
t.Errorf("MyWorkers = %+v, want only alice-box", v.MyWorkers)
|
||||
}
|
||||
|
||||
// An admin is not owner-scoped: they get the fleet and no personal list.
|
||||
if av, _ := dash.Overview(userCtx(uuid.New(), "admin"), 20); len(av.MyWorkers) != 0 || len(av.Workers) != 3 {
|
||||
t.Errorf("admin MyWorkers=%d Workers=%d, want 0 and 3", len(av.MyWorkers), len(av.Workers))
|
||||
}
|
||||
|
||||
// A basic-auth operator (no requester) also gets no personal list.
|
||||
if ov, _ := dash.Overview(context.Background(), 20); len(ov.MyWorkers) != 0 {
|
||||
t.Errorf("operator MyWorkers=%d, want 0", len(ov.MyWorkers))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user