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
4 changes: 3 additions & 1 deletion keel/web/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any

from keel.web import payload
from keel.web import payload, runtime
from keel.web.security import csrf_token

if TYPE_CHECKING: # pragma: no cover - typing only
Expand Down Expand Up @@ -198,11 +198,13 @@ def read_config(cfg: ServeConfig, _query: Query, _state: Any, now_ts: int) -> di
edit gone wrong) must cost the page its badge, not its boot. `payload.config_payload`
records the read-only boundary: nothing in this package writes `auto_trade.mode`.
"""
peers = runtime.live_peers(current_port=cfg.port)
return payload.config_payload(
cfg.build_info,
describe=cfg.build,
mode=_auto_trade_mode(cfg.config_path),
profile=_profile_name(cfg.db_path),
peers=peers,
**_session_state(cfg.db_path, now_ts),
db_path=cfg.db_path,
config_path=cfg.config_path,
Expand Down
2 changes: 2 additions & 0 deletions keel/web/payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -3275,6 +3275,7 @@ def config_payload(
autonomous: bool = False,
db_path: str = "",
config_path: str = "",
peers: Sequence[Mapping[str, Any]] = (),
) -> dict[str, Any]:
"""The running build and the deployment it serves, for the consumers that need either by
name.
Expand Down Expand Up @@ -3350,6 +3351,7 @@ def config_payload(
on_state=GOOD,
off_state=WARN,
),
"peers": list(peers),
}


Expand Down
80 changes: 70 additions & 10 deletions keel/web/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,15 @@ def record_path(port: int) -> Path:
return directory / f"serve-{int(port)}.json"


def record_serving(*, host: str, port: int, token: str, interactive: bool) -> Path | None:
def record_serving(
*,
host: str,
port: int,
token: str,
interactive: bool,
profile: str = "",
mode: str = "",
) -> Path | None:
"""Record how to reach this server, unless a human is watching stdout.

`interactive` is the caller's `sys.stdout.isatty()` -- passed in rather than read here so the
Expand All @@ -123,15 +131,18 @@ def record_serving(*, host: str, port: int, token: str, interactive: bool) -> Pa
# half-written record: `os.replace` is atomic within a directory. The temporary carries the
# same `0600`, because it holds the same token for the moment it exists.
staging = directory / f".serve-{int(port)}.json.tmp"
body = json.dumps(
{
"pid": os.getpid(),
"host": host,
"port": int(port),
"token": token,
"started_ts": int(time.time()),
}
)
data: dict[str, Any] = {
"pid": os.getpid(),
"host": host,
"port": int(port),
"token": token,
"started_ts": int(time.time()),
}
if profile:
data["profile"] = str(profile)
if mode:
data["mode"] = str(mode)
body = json.dumps(data)
# CREATED at `0600`, not corrected to it (#759 review). `Path.write_text` creates at the
# process umask -- measured `0644` under the usual `022` -- and the `chmod` that followed left
# the token world-readable for the window between the two calls. The `0700` directory meant no
Expand Down Expand Up @@ -292,3 +303,52 @@ def stdout_is_interactive() -> bool:
return bool(sys.stdout.isatty())
except AttributeError, ValueError:
return False


def live_peers(current_port: int | None = None) -> list[dict[str, Any]]:
"""All active peer console servers discovered in the runtime directory (#814).

Scans `run_dir()` for all `serve-*.json` records, verifying each against `live_record` (live
process and responding loopback port). Returns structured peer metadata sorted by port, with
wire-safe types (port as string, current as boolean).
"""
directory = run_dir()
if directory is None or not directory.is_dir():
return []

peers: list[dict[str, Any]] = []
try:
entries = sorted(directory.glob("serve-*.json"))
except OSError:
return []

for entry in entries:
name = entry.stem
parts = name.split("-")
if len(parts) != 2:
continue
try:
port = int(parts[1])
except ValueError:
continue

record = live_record(port)
if record is None:
continue

profile = str(record.get("profile", "") or "")
mode = str(record.get("mode", "") or "")
is_current = bool(current_port is not None and port == int(current_port))

peers.append(
{
"port": str(port),
"profile": profile,
"mode": mode,
"url": url_for(record),
"current": is_current,
}
)

peers.sort(key=lambda p: int(p["port"]))
return peers
9 changes: 8 additions & 1 deletion keel/web/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1113,8 +1113,15 @@ def serve(cfg: ServeConfig, *, echo: Callable[[str], None] = print) -> int:
# printing the wrong one would be a false safety assurance about a live credential -- the
# class of thing `payload._session_banner` refuses to do about mode.
interactive = runtime.stdout_is_interactive()
profile = Path(running.db_path).stem if running.db_path else ""
mode = api._auto_trade_mode(running.config_path)
recorded = runtime.record_serving(
host=running.host, port=running.port, token=running.token, interactive=interactive
host=running.host,
port=running.port,
token=running.token,
interactive=interactive,
profile=profile,
mode=mode,
)
if recorded is None:
echo("Stopping keel revokes it: the token is new every run and is never written to disk.")
Expand Down
17 changes: 17 additions & 0 deletions keel/web/static/css/keel.css
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,23 @@ header .sessionpart {
text-overflow: ellipsis;
white-space: nowrap;
}
header .profile-select {
background: var(--bg);
color: var(--muted);
border: 1px solid var(--control-line);
border-radius: 4px;
font-size: 0.85em;
font-family: inherit;
padding: 0.1rem 0.3rem;
max-width: 14ch;
cursor: pointer;
outline: none;
}
header .profile-select:hover,
header .profile-select:focus-visible {
color: var(--fg);
border-color: var(--accent);
}
/* THE SEPARATORS. Generated rather than written into the markup, because each one belongs to the
* part beside it: `display: none` takes a hidden part's pseudo-element with it, so a name that
* never arrived cannot leave a lone middot behind. That is the pairing the `:empty` rule below
Expand Down
8 changes: 8 additions & 0 deletions keel/web/static/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,14 @@ themeNode.addEventListener("click", () => {
}
});

//: In-app profile switcher navigation (#814).
sessionProfileNode.addEventListener("change", (event) => {
const target = /** @type {HTMLSelectElement} */ (event.target);
if (target && target.value && target.value !== window.location.href) {
window.location.href = target.value;
}
});

/** Catch up immediately when a hidden tab is looked at again -- see the poll comment in `show`. */
/**
* The write path (#540): one delegated `submit` listener for every action form.
Expand Down
46 changes: 38 additions & 8 deletions keel/web/static/js/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -3721,14 +3721,23 @@ export function deploymentCard(node, config) {
node.replaceChildren();
return;
}
const peers = config && Array.isArray(config.peers) ? config.peers : [];
const rows = [
kv("deployment", config.profile || "\u2014"),
kv("mode", mode),
kv("config", config.config_path || "\u2014"),
kv("database", config.db_path || "\u2014"),
kv("origin", window.location.origin),
];
if (peers.length !== 0 && peers.length !== 1) {
const peerLabels = peers.map((p) => {
const name = p.profile || String(p.port);
return p.current ? name.concat(" (current)") : name;
});
rows.push(kv("active profiles", peerLabels.join(", ")));
}
node.replaceChildren(
gridCard([
kv("deployment", config.profile || "\u2014"),
kv("mode", mode),
kv("config", config.config_path || "\u2014"),
kv("database", config.db_path || "\u2014"),
kv("origin", window.location.origin),
]),
gridCard(rows),
el("p", "muted", SWITCHING_NOTE),
);
}
Expand All @@ -3747,6 +3756,9 @@ export function deploymentCard(node, config) {
* replacing it, so the badge keeps owning the mode word and its db/config tooltip -- three facts
* reading `profile · mode · equity state`, each written by the one function that knows it.
*
* When multiple peer console daemons are running, the profile node renders an accessible
* selector allowing instant browser navigation between running sessions (#814).
*
* The two halves are filled separately because they are separately absent: a profile is always
* knowable (it is the database's filename), while the equity state can be genuinely unrecorded
* on a deployment that has never flipped modes. `equity_state` is a `Field` carrying that
Expand All @@ -3759,7 +3771,25 @@ export function deploymentCard(node, config) {
*/
export function sessionChip(profileNode, equityNode, config) {
const profile = config && typeof config.profile === "string" ? config.profile : "";
if (profile) {
const peers = config && Array.isArray(config.peers) ? config.peers : [];

if (peers.length !== 0 && peers.length !== 1) {
const options = peers.map((peer) => {
const name = peer.profile || "port ".concat(String(peer.port));
const modeStr = peer.mode ? " (".concat(peer.mode, ")") : "";
const opt = el("option", "", name.concat(modeStr));
if (peer.url) {
opt.setAttribute("value", String(peer.url));
}
if (peer.current) {
opt.setAttribute("selected", "selected");
}
return opt;
});
const select = el("select", "profile-select", ...options);
select.setAttribute("aria-label", "Switch running profile");
profileNode.replaceChildren(select);
} else if (profile) {
profileNode.replaceChildren(document.createTextNode(profile));
} else {
// Empty rather than a placeholder, and `keel.css` hides it while empty -- the same rule the
Expand Down
Loading
Loading