Class: OKF::Server::Hub
- Inherits:
-
Object
- Object
- OKF::Server::Hub
- 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// 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
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: truewould 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 searchis 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.
--warnis the one addition — the graph page never had to draw a middle verdict, and the manager does.body.mgropts 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
-
#bundles ⇒ Object
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. -
#default ⇒ Object
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.
Class Method Summary collapse
-
.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.
Instance Method Summary collapse
- #call(env) ⇒ Object
-
#initialize(bundles, layout: "cose", registry: nil, writable: false) ⇒ Hub
constructor
The first bundle is the one
/redirects to — the registry hands them over in its own order, where first is the default (okf registry defaultmoves an entry to the front), and an ephemeral run takes the dirs as typed.
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
#bundles ⇒ Object (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 |
#default ⇒ Object (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 |