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";
const nodes = signal(null); const loadFailed = signal(false); const status = signal(null); const name = signal("");
const target = signal("");
const pickerOpen = signal(false);
function flash(msg, ok) {
status.value = { msg, ok };
}
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 {
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") {
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;
}
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>
);
}