mobux 0.44.0

A touch-friendly tmux web UI for unhinged people who run terminal sessions from their phone while walking the dog
import { useEffect } from "preact/hooks";
import { signal } from "@preact/signals";
import { apiPutJSON } from "../../lib/api.js";
import { u } from "../../lib/base.js";
import { HostSuggestionSheet } from "../HostSuggestionSheet.jsx";
import {
  Actions,
  Button,
  ConfirmButton,
  FieldRow,
  Group,
  Lede,
  NavRow,
  Status,
} from "./ui.jsx";

// Nodes card — the inventory behind the Home node picker (#176 phase 3).
// A node is {name, target}; the whole list is replaced on every change
// (PUT /api/settings/nodes), mirroring the fixed API contract. SSH keys are
// deliberately out of scope: the hub's key must already be authorized on the
// node, and the card says so instead of growing key-management UI.
//
// Host suggestions (#193): the target field is read-only — tapping it opens
// a full-screen picker (HostSuggestionSheet) whose own text field IS the
// target value while open, so manual entry is never blocked by detection
// finding nothing. It only opens once the list has loaded (`editable`
// below): the same "don't let the field do anything until a real list is
// confirmed" rule that disables the rest of the add form, so a pre-load tap
// can never stage a value for a save that PUTs the whole (unconfirmed)
// list back.

const nodes = signal(null); // null = loading OR load failed — never editable
const loadFailed = signal(false); // true = the initial GET didn't confirm a real list
const status = signal(null); // { msg, ok }
const name = signal("");
const target = signal("");
const pickerOpen = signal(false);

function flash(msg, ok) {
  status.value = { msg, ok };
}

// A load failure must NEVER collapse into an editable `[]` — that's
// indistinguishable from a real "zero nodes configured" server response, and
// Add/Remove do a FULL-LIST PUT (see saveList). A stale empty list from a
// transient GET failure (mid self-update/restart is the common window),
// followed by one "Add", silently wipes every previously-configured node —
// this is exactly how the live nodes table got wiped out from under a
// running remote session. So: nodes.value stays `null` (never `[]`) on any
// failure, loadFailed gates the form, and only a confirmed GET populates a
// real (possibly empty) array.
function markLoadFailed(msg) {
  nodes.value = null;
  loadFailed.value = true;
  flash(msg, false);
}

async function saveList(next) {
  let r;
  try {
    r = await apiPutJSON("/api/settings/nodes", { nodes: next });
  } catch (e) {
    flash(`Save failed: ${e.message}`, false);
    return false;
  }
  if (!r.ok) {
    flash(`Save failed: PUT /api/settings/nodes -> ${r.status}`, false);
    return false;
  }
  nodes.value = next;
  flash("Saved ✓", true);
  return true;
}

async function load() {
  loadFailed.value = false;
  let res;
  try {
    // redirect:"manual" — an absent route lands on the catch-all 307;
    // following it leaves an abandoned body in flight (stalls the page's
    // network-idle state).
    res = await fetch(u("/api/settings/nodes"), {
      headers: { Accept: "application/json" },
      redirect: "manual",
    });
  } catch (e) {
    markLoadFailed(`Failed to load nodes: ${e.message}`);
    return;
  }
  if (res.status === 404 || res.type === "opaqueredirect") {
    // A confirmed, stable signal (this exact server has no node route at
    // all) — not the transient failure this guard is about — so it's safe
    // to treat as a real, editable, empty list.
    nodes.value = [];
    loadFailed.value = false;
    flash("This server has no node support — update mobux.", false);
    return;
  }
  if (!res.ok) {
    res.text().catch(() => {});
    markLoadFailed(
      `Failed to load nodes: GET /api/settings/nodes -> ${res.status}`,
    );
    return;
  }
  let d;
  try {
    d = await res.json();
  } catch (e) {
    markLoadFailed(`Failed to load nodes: ${e.message}`);
    return;
  }
  nodes.value = d.nodes || [];
  loadFailed.value = false;
  status.value = null;
}

// Every mount starts from "loading, not yet editable": the signals are
// module-level and outlive a mount, so a remount must never offer the
// previous mount's list for an Add/Remove before a fresh GET confirms it.
function reload() {
  nodes.value = null;
  loadFailed.value = false;
  pickerOpen.value = false;
  load();
}

function countLabel() {
  if (loadFailed.value) return "Unavailable";
  const list = nodes.value;
  if (list == null) return "…";
  if (list.length === 0) return "None";
  return list.length === 1 ? "1 node" : `${list.length} nodes`;
}

export function NodesRow() {
  useEffect(reload, []);
  return (
    <NavRow
      row="nodes"
      to="/settings/nodes"
      label="Nodes"
      secondary="Remote hosts over SSH"
      value={countLabel()}
    />
  );
}

export function NodesCard() {
  useEffect(reload, []);

  const add = async (e) => {
    e.preventDefault();
    if (nodes.value == null) {
      flash("Nodes haven't loaded yet — retry before adding.", false);
      return;
    }
    const n = name.value.trim();
    const t = target.value.trim();
    if (!n || !t) {
      flash("Both name and SSH target are required.", false);
      return;
    }
    if (nodes.value.some((x) => x.name === n)) {
      flash(`A node named '${n}' already exists.`, false);
      return;
    }
    const ok = await saveList([...nodes.value, { name: n, target: t }]);
    if (ok) {
      name.value = "";
      target.value = "";
    }
  };

  const remove = (n) => {
    if (nodes.value == null) return;
    saveList(nodes.value.filter((x) => x.name !== n));
  };

  const list = nodes.value;
  const editable = list != null;

  return (
    <div id="nodes-settings">
      <Lede>
        Remote hosts this hub opens tmux sessions on over SSH. The hub's SSH key
        must already be authorized on each node; there is no key management
        here.
      </Lede>

      <Group title="Nodes">
        {list == null && !loadFailed.value && (
          <div class="settings-row settings-row--hint">Loading…</div>
        )}
        {list == null && loadFailed.value && (
          <div class="settings-row settings-row--hint">
            <span class="settings-label">
              Could not load the node list — editing is disabled until it does.
            </span>
            <Button id="nodesRetryBtn" class="btn--inline" onClick={load}>
              Retry
            </Button>
          </div>
        )}
        {list &&
          list.map((n) => (
            <div class="settings-row node-row" data-name={n.name} key={n.name}>
              <span class="settings-label">
                <span class="settings-title node-name">{n.name}</span>
                <small class="node-target">{n.target}</small>
              </span>
              <ConfirmButton
                class="btn--inline node-remove"
                aria-label={`Remove ${n.name}`}
                label="Remove"
                confirmLabel="Remove?"
                variant="secondary"
                armedVariant="danger"
                onConfirm={() => remove(n.name)}
              />
            </div>
          ))}
        {list && list.length === 0 && (
          <div class="settings-row settings-row--hint">
            No nodes configured. Sessions run on this host.
          </div>
        )}
      </Group>

      <section class="settings-group">
        <h2>Add a node</h2>
        <form class="settings-card node-add" onSubmit={add}>
          <FieldRow label="Name">
            <input
              id="nodeName"
              class="settings-input"
              placeholder="devbox"
              autocomplete="off"
              disabled={!editable}
              value={name.value}
              onInput={(e) => (name.value = e.target.value)}
            />
          </FieldRow>
          <FieldRow label="SSH target">
            <input
              id="nodeTarget"
              class="settings-input"
              placeholder="user@host"
              autocomplete="off"
              readOnly
              disabled={!editable}
              value={target.value}
              onClick={() => editable && (pickerOpen.value = true)}
            />
          </FieldRow>
          <Actions>
            <Button
              type="submit"
              id="nodeAddBtn"
              variant="primary"
              disabled={!editable}
            >
              Add node
            </Button>
          </Actions>
        </form>
      </section>

      <HostSuggestionSheet
        open={pickerOpen.value}
        value={target.value}
        onChange={(v) => (target.value = v)}
        onClose={() => (pickerOpen.value = false)}
      />

      <Status id="nodesStatus" status={status.value} />
    </div>
  );
}