Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ State / architecture / decisions / "why": Obsidian Vault, `AINode` (cluster ops:
- **A join writes its own keys into `config.json` and touches nothing else** (`cluster/join.py::merge_config_keys`). It merges into the raw JSON; a `NodeConfig.load(); setattr; save()` round trip rewrites the file from the dataclass, so it ADDS every key this release happens to default differently and DROPS every key the dataclass does not declare. A joiner is `cluster_role: "worker"` AND `distributed_mode: "member"`: those are the two spellings of one decision, and `member` is not a value `cluster_role` accepts (the `PATCH /api/config` validator rejects it).
- **The installer generates a `cluster_secret`, and the join flow is how the value reaches a second node** (#169's follow-up). Nothing else distributes it: a node whose secret differs from the rest is dark to them once discovery is signed, so anything that MINTS a secret on an existing cluster has to say so out loud. `ainode config` masks it and `GET /api/config` scrubs it.
- **There is no browser onboarding wizard, and reviving one is not the fix for a first-run gap** (#208). The 19 KB template, its three `/api/onboarding/*` routes and `handle_index`'s redirect to them are deleted: the installer writes `"onboarded": true` and every non-TTY start sets it before the server binds, so `/onboarding` only ever answered a redirect back to `/`, and its complete handler never wrote a cluster key. The TTY wizard (`onboarding/setup.py`) stays. Browser-side joining is one card in `web/static/js/join.js` on a key-protected `POST /api/cluster/join-self`, which restarts nothing (the restart would kill its own response) and `app.js` holds exactly one line that mounts it.
- **A PERSON signs in at the front door and a PROGRAM sends a key, and the browser decides which before it fetches anything else** (#261). `app.js::init` reads `GET /api/auth/me` (open, like `/api/auth/status`) and nothing else: `AINodeAuth.bootDecision` turns that one payload into the shell or the full-page sign-in screen (`web/static/js/signin.js`, never a modal, never a shell of panels that all 401), and a payload that never arrives (a release older than the route) reads as "carry on on the API key" rather than as "signed out". Every request from the dashboard carries `X-AINode-Client: dashboard` and `credentials: 'same-origin'`, both stamped once in `AINodeAuth.headers`/`fetch`, because the server ignores the session cookie on a request that does not carry the header: a call site that builds its own headers is a call site whose cookie is dropped. A 401 is routed by WHICH credential failed: no credential goes back to the front door with a one-line reason, a rejected stored key goes to Config > API access, which is where a key is fixed. Pasting a key into a browser still works and is collapsed, because a browser driving a node it has no account on is a real case. The decisions stay in `auth.js` (DOM-free, so `tests/test_auth_usable.py` runs the whole boot table under node); `signin.js` is markup and focus.
- **An address this node hands to a client is never a loopback spelling, and there is ONE derivation of it** (`api/server_routes.py`, the `own_addresses` / `lan_address` / `peer_host` block). `GET /api/cluster/endpoint` and `/api/status`'s `endpoint_hint` exist so a client stranded by the node it holds can reach another one, so "localhost" (which `/api/nodes` still reports for the local row, deliberately, for the browser on that node) would be the one answer guaranteed wrong on the caller's machine: a node with nothing routable reports an empty host and a null url instead. The local address comes from the request's `Host` header ONLY when it names an address this node really answers on, because that header is caller-controlled and would otherwise let one request set what the node advertises to the next reader; a peer's comes from `peer_host`, which `api/server.py::_node_host` also calls, so the endpoint and the node rows cannot drift apart. Deriving it reads the host (an `ip` subprocess, a routing lookup) and `/api/status` is polled constantly, so it is cached for `ADDRESS_CACHE_SECONDS` keyed on the config fields that feed it: never put a name resolution or a subprocess on that path uncached. `/api/cluster/endpoint` is one of the paths exempt from the API key rule (`auth/middleware.py::SKIP_PATHS`, beside `/api/health`, `/api/auth/status` and `POST /api/cluster/join`) and may carry nothing but names, addresses, ports, schemes, roles and versions: a client that cannot reach its node has no key to present for a route that only tells it where to knock.
- Handoffs use the threadmaster-handoff runbook; ops state lives in `ops/` (runbooks under `ops/runbooks/`).
- Distribution is `docker pull` only, from GHCR (there is no Docker Hub mirror), and end users never hand-edit vLLM commands: the engine emits the flags (see `engine/AGENTS.md`).
Expand Down
327 changes: 327 additions & 0 deletions ainode/web/static/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -6520,3 +6520,330 @@ select {
.metrics-store-warn {
color: var(--amber);
}

/* ============================================================
THE FRONT DOOR (#261): the sign-in screen, the header's user chip,
and the account / user rows under Config.
============================================================ */

/* A full page, not a modal: while this is up the shell is display:none and
nothing else has been fetched. */
.signin-screen {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: var(--bg-primary);
background-image: radial-gradient(circle, #151515 1px, transparent 1px);
background-size: 20px 20px;
overflow-y: auto;
}

body.signin-mode { overflow: hidden; }

.signin-card {
width: 100%;
max-width: 380px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
padding: 28px 26px 20px;
}

.signin-brand {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
}

.signin-logo { width: 30px; height: 30px; }

.signin-wordmark {
font-size: 19px;
font-weight: 700;
letter-spacing: -0.4px;
}
.signin-wordmark-ai { color: var(--nvidia-green); }
.signin-wordmark-node { color: var(--text-primary); }

.signin-title {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 6px;
}

.signin-lead {
font-size: 13px;
line-height: 1.6;
color: var(--text-secondary);
margin-bottom: 18px;
}

.signin-label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
}

.signin-input {
width: 100%;
background: var(--bg-surface);
border: 1px solid var(--border-hover);
border-radius: var(--radius-sm);
padding: 11px 13px;
margin-bottom: 14px;
color: var(--text-primary);
font-size: 14px;
font-family: var(--font-sans);
}
.signin-input:focus {
outline: none;
border-color: var(--nvidia-green);
box-shadow: 0 0 0 2px var(--nvidia-green-glow);
}

.signin-submit {
width: 100%;
background: var(--nvidia-green);
border: 1px solid var(--nvidia-green);
border-radius: var(--radius-sm);
color: #000;
font-family: var(--font-sans);
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
padding: 11px 14px;
margin-top: 4px;
cursor: pointer;
}
.signin-submit:hover:not(:disabled) { background: var(--nvidia-green-bright); }
.signin-submit:disabled { opacity: 0.6; cursor: default; }

/* The one line that says what went wrong, and what to do about it. */
.signin-error {
margin-top: 14px;
padding: 10px 12px;
border: 1px solid var(--red);
border-radius: var(--radius-sm);
background: var(--red-glow);
color: var(--text-primary);
font-size: 12px;
line-height: 1.5;
}

.signin-command {
background: var(--bg-surface);
border: 1px solid var(--border-hover);
border-radius: var(--radius-sm);
padding: 11px 12px;
margin-bottom: 12px;
overflow-x: auto;
}
.signin-command code {
font-family: var(--font-mono);
font-size: 12px;
color: var(--nvidia-green);
white-space: nowrap;
}

.signin-note {
font-size: 11px;
line-height: 1.6;
color: var(--text-secondary);
margin-bottom: 16px;
}
.signin-note code { font-family: var(--font-mono); color: var(--text-primary); }

.signin-programs {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid var(--border);
font-size: 11px;
line-height: 1.6;
color: var(--text-muted);
}

.signin-footer {
margin-top: 12px;
font-size: 11px;
color: var(--text-muted);
display: flex;
align-items: center;
}

/* -- the header chip: who is signed in, and the way out -------------------- */

.user-chip {
display: inline-flex;
align-items: center;
gap: 2px;
border: 1px solid var(--border);
border-radius: 20px;
padding: 2px 3px 2px 2px;
white-space: nowrap;
}

.user-chip-name {
background: transparent;
border: none;
border-radius: 20px;
padding: 3px 9px;
font-family: var(--font-sans);
font-size: 11px;
font-weight: 600;
color: var(--text-primary);
cursor: pointer;
max-width: 20ch;
overflow: hidden;
text-overflow: ellipsis;
}
.user-chip-name:hover { color: var(--nvidia-green); }
.user-chip[data-role="admin"] .user-chip-name::after {
content: "admin";
margin-left: 6px;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.6px;
text-transform: uppercase;
color: var(--nvidia-green);
}

.user-chip-signout {
background: transparent;
border: none;
border-left: 1px solid var(--border);
padding: 3px 9px;
font-family: var(--font-sans);
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
}
.user-chip-signout:hover { color: var(--red); }

/* -- account and user rows ------------------------------------------------- */

/* Flex rather than a grid, and wrapping on the row's OWN width: the config
panel is narrower than the viewport, so a media query cannot tell when three
buttons have squeezed "last seen" into a column three words wide. */
.auth-row {
display: flex;
flex-wrap: wrap;
gap: 10px 12px;
align-items: center;
padding: 13px 0;
border-bottom: 1px solid var(--border);
}
.auth-row:last-child { border-bottom: none; }

.auth-row-main {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
flex: 1 1 260px;
}

.auth-row-name {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}

.auth-row-meta {
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.5;
color: var(--text-secondary);
}

.auth-row-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
margin-left: auto;
}

.auth-badge {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.4px;
text-transform: uppercase;
padding: 2px 7px;
border-radius: 10px;
border: 1px solid var(--border-hover);
color: var(--text-secondary);
}
.auth-badge-admin { border-color: var(--nvidia-green); color: var(--nvidia-green); }
.auth-badge-member { border-color: var(--border-hover); color: var(--text-secondary); }
.auth-badge-current { border-color: var(--cyan); color: var(--cyan); }
.auth-badge-off { border-color: var(--amber); color: var(--amber); }

.auth-form { display: block; margin-top: 4px; }
.auth-form .form-input { margin-bottom: 12px; }

.auth-form-add {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto auto;
gap: 10px;
align-items: center;
}
.auth-form-add .form-input { margin-bottom: 0; }

.auth-inline-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
margin-top: 6px;
}

@media (max-width: 720px) {
.auth-form-add { grid-template-columns: minmax(0, 1fr); }
.auth-row-actions { justify-content: flex-start; margin-left: 0; }
}

/* -- the collapsed "paste a key" block in API access ----------------------- */

.config-details {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
margin-top: 10px;
}
.config-details > summary {
cursor: pointer;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
list-style: none;
}
.config-details > summary::-webkit-details-marker { display: none; }
.config-details > summary::before {
content: "\25B8";
margin-right: 8px;
color: var(--text-muted);
}
.config-details[open] > summary::before { content: "\25BE"; }
.config-details > summary:hover { color: var(--text-primary); }

/* The person, under the port's own wording, in the API access Status card. */
.api-access-who {
display: block;
margin-top: 4px;
font-size: 11px;
color: var(--text-secondary);
}
Loading
Loading