Class: OKF::Server::Hub

Inherits:
Object
  • Object
show all
Defined in:
lib/okf/server/hub.rb,
lib/okf/server/hub/not_found.rb

Overview

Multiplexes N bundles behind one server. Each bundle is mounted at /b// and served by its own OKF::Server::App; / redirects to the default bundle (explicitly chosen, or the first), or shows an empty-state page when none are registered. The graph page is already mount-relative (its fetch endpoints are relative), so hosting under a prefix needs only a clean PATH_INFO strip here plus a trailing-slash redirect. Part of the shell — it is a Rack app.

GET /               302 -> /b/<default>/   (empty-state page when no bundles)
GET /search?q=…     ranked concepts across *every* hosted bundle (JSON) —
                  the only route that answers about the whole set, and
                  so the one route that can only live here
GET /b/             the bundles list — every bundle, its health, and the
                  way into it. Read-only: managing the registry belongs
                  to the graph page's Bundles panel, which is where the
                  reader already is
GET /b/<slug>       301 -> /b/<slug>/      (query string preserved)
GET /b/<slug>/...   delegated to that bundle's App (the prefix stripped)
GET (unknown slug)  404 on the app shell (Hub::NotFound) — the asked
                  path, a did-you-mean, and the hosted bundles, so a
                  stale bookmark after a rename gets a way home
POST /registry/<verb>  default | rename | remove | add — the only routes
                  that change anything, gated four ways (see #write),
                  answering JSON to the Bundles panel's fetch()

bundles is an ordered array of Hub::Bundle (slug, folder, title). Apps are built up front, each carrying the other bundles as siblings so the in-page switcher can jump between them; static okf render files get no siblings and so cannot switch.

Defined Under Namespace

Classes: Bundle, NotFound

Constant Summary collapse

MOUNT =
"/b"
WRITES =

The registry verbs POST reaches, and nothing else. A list rather than a method lookup: the route is user input, and "whatever method the path names" is how a router becomes an eval.

%w[default rename remove add].freeze
SEARCH_LIMIT =

How many rows /search answers with. The palette shows a handful and the rest is scroll nobody reaches, but the count is reported alongside so a capped answer never reads as a complete one.

50
SEARCH_ENGINE =

The engine /search runs on, named rather than inferred. fuzzy: true would route here on its own today — the index is the only registered engine that offers it — but that is correctness by coincidence, and an addon declaring :fuzzy would silently take the route.

It is also the right engine here for a reason the CLI's default does not share: okf search is one-shot and cannot amortize an index build, while this is a long-lived server answering keystroke after keystroke. And the page's own MiniSearch is what minifts is a port of, so a palette hit and an in-page search rank alike instead of nearly alike.

:index
STYLE =

Shared style for the hub's own pages (empty landing, /b/ manager, 404) — self-contained and theme-aware, no external requests, in keeping with the graph page's own no-CDN-at-rest rule.

The tokens are the graph page's own values, not a second palette: this is the same product, and a bundle index that looks like a different app is worse than a plain list. --warn is the one addition — the graph page never had to draw a middle verdict, and the manager does.

body.mgr opts out of the centred card the landing and the 404 want. A one-paragraph page centres well; a list of bundles does not.

<<~CSS
  :root{--bg:#f4f5f7;--panel:#ffffff;--ink:#1f2328;--muted:#63697a;--faint:#9298a4;
  --line:#e6e8eb;--line-2:#eef0f2;--accent:#e21e1e;--ok:#1a9e5f;--warn:#b7791f;--err:#c81a1a}
  @media(prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1d2026;--ink:#eceef1;--muted:#9aa0aa;--faint:#6b7178;
  --line:#2a2e36;--line-2:#232830;--accent:#f5433b;--ok:#37c07f;--warn:#e0a13a;--err:#ff726b}}
  body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--bg);color:var(--ink);
  font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
  main{max-width:34rem;width:calc(100% - 4rem);padding:2rem}h1{font-size:1.3rem;margin:0 0 .5rem}
  code{background:var(--line);padding:.15rem .4rem;border-radius:.35rem}
  .def{margin-left:.5rem;padding:.05rem .45rem;border-radius:99px;background:var(--line);font-size:.75rem}

  /* ── the bundles manager ── */
  body.mgr{display:block;place-items:initial}
  body.mgr main{max-width:60rem;width:auto;margin:0 auto;padding:3rem 2rem 4rem}
  .mhead{margin-bottom:1.5rem}
  /* the same 3px accent rule the graph page draws under a section head */
  .mhead h1{margin:0;padding-bottom:.55rem;position:relative;font-size:1.45rem;letter-spacing:-.01em}
  .mhead h1::after{content:"";position:absolute;left:0;bottom:0;width:34px;height:3px;border-radius:3px;background:var(--accent)}
  .mhead .sub{margin:.7rem 0 0;color:var(--muted);font-size:.9rem}
  ol.rows{list-style:none;margin:0;padding:0;border-top:1px solid var(--line)}
  .row{position:relative;display:flex;flex-wrap:wrap;gap:.5rem 1.5rem;align-items:baseline;
  padding:.95rem .9rem;border-bottom:1px solid var(--line)}
  .row:hover{background:var(--line-2)}
  /* The verdict as a left edge — the head's accent rule, stood on end and
     put to work. Colour only reinforces it; the word beside it is the
     message, so nothing here depends on being able to see red. */
  .row::before{content:"";position:absolute;left:0;top:.55rem;bottom:.55rem;width:3px;border-radius:0 3px 3px 0;background:var(--line)}
  .row[data-health=ok]::before{background:var(--ok)}
  .row[data-health=warn]::before{background:var(--warn)}
  .row[data-health=error]::before{background:var(--err)}
  .who{flex:1 1 16rem;min-width:0}
  .who .name{color:var(--ink);font-weight:600;text-decoration:none;font-size:1rem}
  .who .name:hover{text-decoration:underline}
  .who .name.off{color:var(--muted);font-weight:500}
  /* nowrap, because @slug and the folder are one identity read left to
     right — split over two lines they read as two facts */
  .ref{margin-top:.15rem;display:flex;flex-wrap:nowrap;gap:.55rem;align-items:baseline;
  font:12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--faint)}
  .ref .slug{flex:none}
  /* Monospace is not decoration here: @slug is what you type at the CLI
     and the folder is a real path. Both are literals, so both are set as
     literals. */
  .ref .slug{color:var(--muted)}
  /* Truncate a long path from the *left*: the tail (…/repo/.okf) is the
     part that identifies it, and clipping the tail identifies nothing.
     An rtl box puts the ellipsis at the front — but a leading "/" is a
     neutral character and would reorder to the far end, printing
     "…/repo/.okf/" for a path that has no trailing slash. The inner <bdi>
     isolates the path as one ltr run, so nothing in it moves. */
  .ref .dir{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}
  .ref .dir bdi{direction:ltr}
  /* Fixed slots, right-aligned as a block: these are columns of counts and
     verdicts read down as much as across, and a ragged column of numbers
     is a column nobody scans. The last slot stays reserved even when the
     row is not the default, so the two before it cannot shift. */
  .facts{margin-left:auto;display:flex;gap:1.1rem;align-items:baseline;font-size:.85rem;color:var(--muted);white-space:nowrap}
  .facts span{display:inline-block}
  .f-count{min-width:6.5rem;text-align:right}
  .f-health{min-width:8.5rem}
  .f-flag{min-width:4.6rem}
  .hv-word{color:var(--muted)}
  .row[data-health=warn] .hv-word{color:var(--warn)}
  .row[data-health=error] .hv-word{color:var(--err)}
  .row[data-health=missing] .who .name{color:var(--faint)}
  .mnote{margin:1.4rem 0 0;color:var(--faint);font-size:.85rem}

  /* Stacked, the fixed slots stop being columns and become indentation on
     a row that has nothing to put in one — so they collapse to their
     content, and an empty one takes no space at all. */
  @media(max-width:640px){body.mgr main{padding:2rem 1.1rem 3rem}
  .facts{margin-left:0;gap:.9rem}.f-count,.f-health,.f-flag{min-width:0;text-align:left}
  .facts span:empty{display:none}}
CSS

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(bundles, layout: "cose", registry: nil, writable: false) ⇒ Hub

The first bundle is the one / redirects to — the registry hands them over in its own order, where first is the default (okf registry default moves an entry to the front), and an ephemeral run takes the dirs as typed. registry is the OKF::Registry this hub was booted from, when it was. It is what separates the two kinds of hub: a registry-backed one can report on entries it could not host (a folder that has since been deleted), and an ephemeral one (okf server ./a ./b) has no such list and says so. The object carries its own path, so the manager re-reads the file per request rather than trusting a snapshot taken at boot. writable decides whether the manager offers the registry forms and whether the POST routes answer at all. The CLI sets it: a loopback bind gets it for free and --read-only declines it, while any other address is refused outright with no flag that says otherwise — --bind 0.0.0.0 turns a personal tool into a public one, and the write surface does not follow it there at all.



174
175
176
177
178
179
180
181
# File 'lib/okf/server/hub.rb', line 174

def initialize(bundles, layout: "cose", registry: nil, writable: false)
  @bundles = bundles
  @default = bundles.first
  @boot_registry = registry
  @layout = layout
  @writable = writable
  @apps = build_apps(layout)
end

Instance Attribute Details

#bundlesObject (readonly)

The hosted bundles in mount order, and the one / redirects to — so a caller printing the mount table asks the hub instead of re-deriving the rule and drifting from it.



157
158
159
# File 'lib/okf/server/hub.rb', line 157

def bundles
  @bundles
end

#defaultObject (readonly)

The hosted bundles in mount order, and the one / redirects to — so a caller printing the mount table asks the hub instead of re-deriving the rule and drifting from it.



157
158
159
# File 'lib/okf/server/hub.rb', line 157

def default
  @default
end

Class Method Details

.bundles_for(registry) ⇒ Object

Load every registered entry the hub can actually serve, skipping the ones it cannot and yielding each skipped entry so a caller with a terminal can say so. One implementation, used at boot by the CLI and again after every write — a second copy is a second answer waiting to disagree.



187
188
189
190
191
192
# File 'lib/okf/server/hub.rb', line 187

def self.bundles_for(registry)
  registry.each_with_object([]) do |entry, bundles|
    bundle = load_entry(entry)
    bundle ? bundles << bundle : (yield entry if block_given?)
  end
end

Instance Method Details

#call(env) ⇒ Object



208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'lib/okf/server/hub.rb', line 208

def call(env)
  request = Rack::Request.new(env)
  # Everything this class *emits* must carry the prefix a host mounted it
  # under; PATH_INFO is already relative to it.
  base = env["SCRIPT_NAME"].to_s
  return write(request) if request.post? && request.path_info.start_with?("/registry/")
  return not_found unless request.get?

  path = request.path_info
  query = request.query_string.to_s
  return landing(base, query) if [ "", "/" ].include?(path)
  return search(request.params["q"]) if path == "/search"
  return bundles_json if path == "/bundles"
  return html(200, index_page(base)) if [ MOUNT, "#{MOUNT}/" ].include?(path)

  slug, rest = split(path)
  app = slug && @apps[slug]
  return html(404, missing_page(base, path, slug)) unless app
  return redirect("#{base}#{MOUNT}/#{slug}/", 301, query) if rest.empty?

  app.call(mounted(env, slug, rest))
end