refactor(hosts): remove management settings UI from phase one

Keep connected-host management in the CLI and shared SDK while preserving app saved connections and plugin access.
This commit is contained in:
Mohamed Boudra
2026-10-20 07:59:01 +02:00
parent fb4ca20dbd
commit f2dfa83e83
7 changed files with 5 additions and 758 deletions
+2 -3
View File
@@ -1,8 +1,7 @@
# Connected hosts
A saved host belongs to the daemon that stores it. The app's saved connections remain a separate list.
Connect A to B in A's settings under **Hosts connected to this daemon**, through `paseo --host A host`,
or through `paseo.hosts`. Consumers select B by its learned `serverId`; management uses the stable
Connect A to B through `paseo --host A host` or `paseo.hosts`. Consumers select B by its learned `serverId`; management uses the stable
`registrationId` created before B is reachable.
## Ownership and authority
@@ -27,7 +26,7 @@ current authority. A routed API cannot select a second hop, and saving a host gr
A producer that registers a host through a routed API is qualified on B as
`route:<B principal>:<A serverId>:<A producer>`, with each component percent-encoded. This namespace belongs to the authenticated link
principal. An ordinary direct B consumer does not become that producer by supplying a source key.
The optional `server_info.sourceId` reports this producer identity for source-owner controls; it does not describe the authenticated admission principal.
The optional `server_info.sourceId` reports this producer identity separately from the authenticated admission principal.
Trusted server plugins retain the authority of their runtime; they are not sandboxed by this feature.
## Connections and recovery
@@ -1,99 +0,0 @@
import { test } from "../support/fixtures";
import {
expectConnectedHostControlsFit,
closeConnectedHostDetails,
toggleConnectedHost,
openRestartableConnectedHosts,
beginConnectedHostDraft,
reconnectManagingHost,
expectConnectedHostDraftRetained,
saveConnectedHost,
openConnectedHosts,
openCompactConnectedHosts,
addConnectedHost,
expectConnectionError,
correctConnection,
inspectConnectedHost,
expectSavedHostDetails,
disableConnectedHost,
enableConnectedHost,
editConnectedHost,
removeConnectedHost,
} from "../support/helpers/connected-hosts";
test("invalid input stays editable; unreachable hosts remain manageable", async ({
page,
}, testInfo) => {
await openConnectedHosts(page);
await addConnectedHost(page, "ssh://-invalid", "Build box");
await expectConnectionError(page);
await correctConnection(page, "tcp://127.0.0.1:1?password=private-secret");
await inspectConnectedHost(page, "Build box");
await expectSavedHostDetails(page);
await page.screenshot({ path: testInfo.outputPath("connected-host-details.png") });
await editConnectedHost(page);
await disableConnectedHost(page);
await enableConnectedHost(page);
await removeConnectedHost(page, "Renamed box");
});
test.describe("compact connected hosts", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("saved hosts can be inspected and disabled on a phone layout", async ({
page,
}, testInfo) => {
await openCompactConnectedHosts(page);
await addConnectedHost(page, "tcp://127.0.0.1:1", "Compact host");
await inspectConnectedHost(page, "Compact host");
await expectSavedHostDetails(page);
await page.screenshot({
path: testInfo.outputPath("connected-host-compact.png"),
fullPage: true,
});
await disableConnectedHost(page);
await removeConnectedHost(page, "Compact host");
});
});
test("connection loss retains the unsaved name and credential input", async ({ page }) => {
const host = await openRestartableConnectedHosts(page);
try {
await beginConnectedHostDraft(page);
await reconnectManagingHost(host);
await expectConnectedHostDraftRetained(page);
await saveConnectedHost(page);
await inspectConnectedHost(page, "Retained draft");
} finally {
await host.close();
}
});
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
test.describe("compact connected host controls", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("long host names keep both actions visible with details open and closed", async ({
page,
}, testInfo) => {
const destination = await startIsolatedHostDaemon("srv_connected_layout");
const label = "Production build host — development-infrastructure-europe-west";
try {
await openCompactConnectedHosts(page);
await addConnectedHost(page, `127.0.0.1:${destination.port}`, label);
await expectConnectedHostControlsFit(page, label, true);
await inspectConnectedHost(page, label);
await expectConnectedHostControlsFit(page, label, true);
await closeConnectedHostDetails(page);
await toggleConnectedHost(page, true);
await expectConnectedHostControlsFit(page, label, false);
await inspectConnectedHost(page, label);
await expectConnectedHostControlsFit(page, label, false);
await page.screenshot({ path: testInfo.outputPath("connected-host-controls.png") });
await closeConnectedHostDetails(page);
await toggleConnectedHost(page, false);
await expectConnectedHostControlsFit(page, label, true);
} finally {
await destination.close();
}
});
});
@@ -1,132 +0,0 @@
import { expect, type Page } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
import { openCompactSettings, openHostSection } from "./settings";
import { getServerId } from "./server-id";
export async function openConnectedHosts(page: Page) {
await gotoAppShell(page);
await openSettings(page);
await openHostSection(page, getServerId(), "host");
await expect(page.getByText("No hosts connected to this daemon", { exact: true })).toBeVisible();
}
export async function addConnectedHost(page: Page, connection: string, label: string) {
await page.getByRole("button", { name: "Add host", exact: true }).click();
await page.getByRole("textbox", { name: "Connection string", exact: true }).fill(connection);
await page.getByRole("textbox", { name: "Host name", exact: true }).fill(label);
await page.getByRole("textbox", { name: "Metadata", exact: true }).fill('{"team":"infra"}');
await saveConnectedHost(page);
}
export async function saveConnectedHost(page: Page) {
await page.getByRole("button", { name: "Save host", exact: true }).click();
}
export async function expectConnectionError(page: Page) {
await expect(page.getByText("Invalid host connection.", { exact: false })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Connection string", exact: true })).toBeVisible();
}
export async function correctConnection(page: Page, value: string) {
await page.getByRole("textbox", { name: "Connection string", exact: true }).fill(value);
await saveConnectedHost(page);
}
export async function inspectConnectedHost(page: Page, label: string) {
await expect(
page.getByText(/^(connecting|offline|error|online|disabled) · (Enabled|Disabled) · User$/),
).toBeVisible();
await page.getByRole("button", { name: `Details for ${label}`, exact: true }).click();
await expect(page.getByText("Connected host details", { exact: true })).toBeVisible();
}
export async function expectSavedHostDetails(page: Page) {
await expect(page.getByText('{"team":"infra"}', { exact: true })).toBeVisible();
await expect(page.getByText("Never", { exact: true })).toBeVisible();
await expect(
page.getByRole("textbox", { name: "Replacement connection", exact: true }),
).toHaveValue("");
}
export async function disableConnectedHost(page: Page) {
await page.getByRole("button", { name: "Cancel", exact: true }).click();
await page.getByRole("button", { name: "Disable", exact: true }).click();
await expect(page.getByText(/disabled · Disabled/)).toBeVisible();
}
export async function enableConnectedHost(page: Page) {
await page.getByRole("button", { name: "Enable", exact: true }).click();
await expect(page.getByRole("button", { name: "Disable", exact: true })).toBeVisible();
}
export async function removeConnectedHost(page: Page, label: string) {
await inspectConnectedHost(page, label);
page.once("dialog", (dialog) => dialog.accept());
await page.getByRole("button", { name: "Remove host", exact: true }).click();
await expect(page.getByText("No hosts connected to this daemon", { exact: true })).toBeVisible();
}
export async function openCompactConnectedHosts(page: Page) {
await gotoAppShell(page);
await openCompactSettings(page, new URL(page.url()).pathname);
await openHostSection(page, getServerId(), "host");
await expect(page.getByText("No hosts connected to this daemon", { exact: true })).toBeVisible();
}
import { startIsolatedHostDaemon, type IsolatedHostDaemon } from "./isolated-host-daemon";
import { addDirectHostFromSettings, selectSettingsHost } from "./settings";
export async function openRestartableConnectedHosts(page: Page): Promise<IsolatedHostDaemon> {
const host = await startIsolatedHostDaemon("srv_connected_host_draft");
await gotoAppShell(page);
await openSettings(page);
await addDirectHostFromSettings(page, { host: "127.0.0.1", port: host.port });
await selectSettingsHost(page, host.serverId);
await openHostSection(page, host.serverId, "host");
await expect(page.getByText("No hosts connected to this daemon", { exact: true })).toBeVisible();
return host;
}
export async function beginConnectedHostDraft(page: Page) {
await page.getByRole("button", { name: "Add host", exact: true }).click();
await page.getByRole("textbox", { name: "Host name", exact: true }).fill("Retained draft");
await page
.getByRole("textbox", { name: "Connection string", exact: true })
.fill("tcp://127.0.0.1:1?password=draft-secret");
}
export async function reconnectManagingHost(host: IsolatedHostDaemon) {
await host.restart();
}
export async function expectConnectedHostDraftRetained(page: Page) {
await expect(page.getByRole("textbox", { name: "Host name", exact: true })).toHaveValue(
"Retained draft",
{ timeout: 30000 },
);
await expect(page.getByRole("textbox", { name: "Connection string", exact: true })).toHaveValue(
"tcp://127.0.0.1:1?password=draft-secret",
);
}
export async function editConnectedHost(page: Page) {
await page.getByRole("switch", { name: "Replace destination identity", exact: true }).click();
await saveConnectedHost(page);
await expect(
page.getByText("Identity replacement requires a replacement connection", { exact: true }),
).toBeVisible();
await page
.getByRole("textbox", { name: "Replacement connection", exact: true })
.fill("tcp://127.0.0.1:1?password=replacement-secret");
await page.getByRole("textbox", { name: "Host name", exact: true }).fill("Renamed box");
await page.getByRole("textbox", { name: "Metadata", exact: true }).fill('{"team":"tools"}');
await saveConnectedHost(page);
await inspectConnectedHost(page, "Renamed box");
await expect(page.getByText('{"team":"tools"}', { exact: true })).toBeVisible();
await expect(
page.getByRole("textbox", { name: "Replacement connection", exact: true }),
).toHaveValue("");
}
export async function expectConnectedHostControlsFit(page: Page, label: string, enabled: boolean) {
const details = page.getByRole("button", { name: `Details for ${label}`, exact: true });
const toggle = page.getByRole("button", { name: enabled ? "Disable" : "Enable", exact: true });
await details.scrollIntoViewIfNeeded();
await expect(details).toBeInViewport({ ratio: 1 });
await expect(toggle).toBeInViewport({ ratio: 1 });
}
export async function closeConnectedHostDetails(page: Page) {
await page.getByRole("button", { name: "Cancel", exact: true }).click();
}
export async function toggleConnectedHost(page: Page, enabled: boolean) {
await page.getByRole("button", { name: enabled ? "Disable" : "Enable", exact: true }).click();
await expect(page.getByText(enabled ? /disabled · Disabled/ : /online · Enabled/)).toBeVisible();
}
+3 -13
View File
@@ -31,7 +31,6 @@ export { SettingsCollapsibleRow } from "./collapsible-row";
interface AppSettingsRowProps extends Omit<SettingsRowProps, "hint"> {
hint?: ReactNode;
labelAccessory?: ReactNode;
stacked?: boolean;
}
export function SettingsCard({ children, testID }: { children: ReactNode; testID?: string }) {
@@ -56,16 +55,12 @@ export function SettingsRow({
error,
children,
testID,
stacked = false,
}: AppSettingsRowProps) {
const compact = useIsCompactFormFactor();
const rowStyle = useMemo(
() => [settingsStyles.row, compact && styles.compactRow, stacked && styles.stackedRow],
[compact, stacked],
);
const rowStyle = useMemo(() => [settingsStyles.row, compact && styles.compactRow], [compact]);
return (
<View style={rowStyle} testID={testID}>
<View style={[styles.label, stacked && styles.stackedLabel]}>
<View style={styles.label}>
{labelAccessory ? (
<View style={styles.labelRow}>
<Text style={[settingsStyles.rowTitle, styles.accessoryLabel]}>{label}</Text>
@@ -81,9 +76,7 @@ export function SettingsRow({
</Text>
) : null}
</View>
{children ? (
<View style={[styles.control, stacked && styles.stackedControl]}>{children}</View>
) : null}
{children ? <View style={styles.control}>{children}</View> : null}
</View>
);
}
@@ -200,9 +193,6 @@ export function SettingsAction({ actionLabel, onPress, disabled, ...row }: Setti
}
const styles = StyleSheet.create((theme) => ({
stackedRow: { flexDirection: "column", alignItems: "stretch", gap: theme.spacing[3] },
stackedLabel: { flexBasis: "auto", marginRight: 0, minWidth: 0 },
stackedControl: { width: "100%" },
compactRow: { flexWrap: "wrap", gap: theme.spacing[3] },
label: { flexGrow: 1, flexShrink: 1, flexBasis: 160, marginRight: theme.spacing[3] },
labelRow: {
-317
View File
@@ -1,317 +0,0 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { createPaseoApi, type HostSummary, type HostDirectory } from "@getpaseo/client";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import {
SettingsCard,
SettingsRow,
SettingsInput,
SettingsSwitch,
SettingsSection,
} from "@/components/settings";
import { Button } from "@/components/ui/button";
import { Field, FormTextInput } from "@/components/ui/form-field";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useIsCompactFormFactor } from "@/constants/layout";
import { settingsStyles } from "@/styles/settings";
import { confirmDialog } from "@/utils/confirm-dialog";
import { ConnectedHostsModel, type Editor } from "./model";
export function ConnectedHostsSection({ serverId }: { serverId: string }) {
const client = useHostRuntimeClient(serverId);
const connected = useHostRuntimeIsConnected(serverId);
return (
<SettingsSection title="Hosts connected to this daemon">
{client ? (
<ConnectedHosts key={serverId} client={client} connected={connected} />
) : (
<SettingsCard>
<SettingsRow label="Connect to this daemon to manage its hosts" />
</SettingsCard>
)}
</SettingsSection>
);
}
function useModel(client: DaemonClient, connected: boolean) {
const [model] = useState(() => new ConnectedHostsModel(createPaseoApi(client)));
useEffect(() => {
model.setConnected(connected);
}, [model, connected]);
useEffect(() => model.close, [model]);
return model;
}
function ConnectedHosts({ client, connected }: { client: DaemonClient; connected: boolean }) {
const model = useModel(client, connected);
const state = useSyncExternalStore(model.subscribe, model.getState, model.getState);
const info = client.getLastServerInfoMessage();
const canManage = info?.permissions?.includes("tunnel.manage") === true;
const directory = state.load.status === "loaded" ? state.load.directory : null;
const editor = state.editor;
return (
<View style={styles.section}>
{state.load.status === "loading" ? (
<SettingsCard>
<SettingsRow label="Loading connected hosts…" />
</SettingsCard>
) : null}
{state.load.status === "error" ? (
<SettingsCard>
<SettingsRow label="Could not load connected hosts" error={state.load.error}>
<Button variant="outline" size="sm" onPress={model.start}>
Retry
</Button>
</SettingsRow>
</SettingsCard>
) : null}
{directory ? (
<HostList
directory={directory}
model={model}
canManage={canManage}
pending={state.pending !== null}
/>
) : null}
{directory && canManage && !editor ? (
<Button variant="outline" size="sm" disabled={state.pending !== null} onPress={model.add}>
Add host
</Button>
) : null}
{editor ? (
<HostEditor
key={editor.key}
editor={editor}
directory={directory}
model={model}
canManage={canManage}
manageSources={info?.permissions?.includes("access.manage") === true}
sourceId={info?.sourceId}
pending={state.pending !== null}
/>
) : null}
{state.error ? (
<SettingsCard>
<SettingsRow label="Host change failed" error={state.error}>
<Button variant="ghost" size="sm" onPress={model.dismissError}>
Dismiss
</Button>
</SettingsRow>
</SettingsCard>
) : null}
{state.pending ? (
<Text accessibilityLiveRegion="polite" style={settingsStyles.rowHint}>
Saving host change…
</Text>
) : null}
</View>
);
}
function HostList({
directory,
model,
canManage,
pending,
}: {
directory: HostDirectory;
model: ConnectedHostsModel;
canManage: boolean;
pending: boolean;
}) {
return (
<SettingsCard>
{directory.hosts.length === 0 ? (
<SettingsRow label="No hosts connected to this daemon" />
) : (
directory.hosts.map((host) => (
<HostRow
key={host.registrationId}
host={host}
model={model}
canManage={canManage}
pending={pending}
/>
))
)}
</SettingsCard>
);
}
function HostRow({
host,
model,
canManage,
pending,
}: {
host: HostSummary;
model: ConnectedHostsModel;
canManage: boolean;
pending: boolean;
}) {
const compact = useIsCompactFormFactor();
const edit = useCallback(() => model.edit(host), [host, model]);
const toggle = useCallback(() => void model.setEnabled(host), [host, model]);
return (
<SettingsRow
stacked={compact}
label={host.label ?? host.serverId ?? host.registrationId}
hint={`${host.status} · ${host.enabled ? "Enabled" : "Disabled"} · ${sourceLabel(host.source.id)}`}
>
<View style={styles.actions}>
<Button
variant="outline"
size="sm"
disabled={pending}
onPress={edit}
accessibilityLabel={`Details for ${host.label ?? host.registrationId}`}
>
Details
</Button>
{canManage ? (
<Button variant="outline" size="sm" disabled={pending} onPress={toggle}>
{host.enabled ? "Disable" : "Enable"}
</Button>
) : null}
</View>
</SettingsRow>
);
}
function HostDetails({ host }: { host: HostSummary }) {
return (
<SettingsCard>
<SettingsRow label="Registration" hint={host.registrationId} />
<SettingsRow label="Server ID" hint={host.serverId ?? "Not discovered"} />
<SettingsRow label="Connection" hint={host.targetDescription} />
<SettingsRow label="Metadata" hint={JSON.stringify(host.metadata)} />
<SettingsRow label="Status" hint={host.status} />
<SettingsRow label="Source" hint={sourceLabel(host.source.id)} />
<SettingsRow label="Source identity" hint={host.source.id} />
<SettingsRow label="Source key" hint={host.source.key} />
<SettingsRow label="Last online" hint={host.lastOnlineAt ?? "Never"} />
<SettingsRow label="Next retry" hint={host.nextRetryAt ?? "None scheduled"} />
{host.error ? <SettingsRow label={host.error.code} error={host.error.message} /> : null}
</SettingsCard>
);
}
function HostEditor({
editor,
directory,
model,
canManage,
manageSources,
sourceId,
pending,
}: {
editor: Editor;
directory: HostDirectory | null;
model: ConnectedHostsModel;
canManage: boolean;
manageSources: boolean;
sourceId: string | undefined;
pending: boolean;
}) {
const compact = useIsCompactFormFactor();
const detail =
directory?.hosts.find((host) => host.registrationId === editor.host?.registrationId) ??
editor.host;
const editSource = !detail || manageSources || detail.source.id === sourceId;
const remove = useCallback(async () => {
if (!detail) return;
const confirmed = await confirmDialog({
title: "Remove connected host?",
message:
detail.source.id === "owner"
? "This removes the saved connection. The host's agents and workspaces keep running."
: `Managed by ${detail.source.id}. Its source can add it again. Disable the host to keep access off.`,
confirmLabel: "Remove",
destructive: true,
});
if (confirmed) await model.remove(detail);
}, [detail, model]);
return (
<View style={styles.section}>
<Text style={settingsStyles.rowTitle}>{detail ? "Connected host details" : "Add host"}</Text>
{detail ? <HostDetails host={detail} /> : null}
{canManage ? (
<SettingsCard>
<SettingsInput
label="Host name"
initialValue={editor.label}
onChangeText={model.setLabel}
disabled={pending}
/>
{editSource ? (
<SettingsInput
label={detail ? "Replacement connection" : "Connection string"}
initialValue={editor.connection}
onChangeText={model.setConnection}
secureTextEntry
disabled={pending}
/>
) : null}
{detail && editSource ? (
<SettingsSwitch
label="Replace destination identity"
value={editor.replaceIdentity}
onValueChange={model.setReplaceIdentity}
disabled={pending}
/>
) : null}
</SettingsCard>
) : null}
{canManage && editSource ? (
<Field label="Metadata">
<FormTextInput
accessibilityLabel="Metadata"
initialValue={editor.metadata}
onChangeText={model.setMetadata}
multiline
editable={!pending}
size={compact ? "md" : "sm"}
/>
</Field>
) : null}
<View style={styles.actions}>
{canManage ? (
<Button
variant="default"
size="sm"
loading={pending}
disabled={pending}
onPress={model.save}
>
Save host
</Button>
) : null}
<Button variant="ghost" size="sm" disabled={pending} onPress={model.cancel}>
Cancel
</Button>
{canManage && detail && editSource ? (
<Button variant="outline" size="sm" disabled={pending} onPress={remove}>
Remove host
</Button>
) : null}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
section: { gap: theme.spacing[3] },
actions: { flexDirection: "row", flexWrap: "wrap", gap: theme.spacing[2] },
}));
function sourceLabel(sourceId: string): string {
if (sourceId === "owner") return "User";
if (sourceId.startsWith("plugin:")) return `Plugin: ${sourceId.slice("plugin:".length)}`;
if (sourceId.startsWith("route:")) {
const components = sourceId.split(":");
let producer: string;
try {
producer = decodeURIComponent(components[3] ?? "");
} catch {
return "User (via host)";
}
return producer.startsWith("plugin:")
? `Plugin: ${producer.slice("plugin:".length)} (via host)`
: "User (via host)";
}
return "User";
}
-192
View File
@@ -1,192 +0,0 @@
import type {
PaseoApi,
HostSummary,
HostUpdate,
HostDirectory,
OwnedSubscription,
} from "@getpaseo/client";
export interface Editor {
key: number;
host: HostSummary | null;
connection: string;
label: string;
metadata: string;
replaceIdentity: boolean;
}
type Load =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; directory: HostDirectory };
interface State {
load: Load;
editor: Editor | null;
pending: string | null;
error: string | null;
}
/** Owns fetch/observation, form sequencing and mutation state outside React. */
export class ConnectedHostsModel {
private state: State = { load: { status: "loading" }, editor: null, pending: null, error: null };
private readonly listeners = new Set<() => void>();
private observation?: OwnedSubscription<HostDirectory>;
private generation = 0;
private editorKey = 0;
private connected = false;
setConnected = (connected: boolean) => {
this.connected = connected;
if (connected && !this.observation) this.start();
else if (!connected)
this.publish({
load: {
status: "error",
error:
"Connection to this daemon was lost. Reconnect to continue; your edits are retained.",
},
});
};
constructor(private readonly api: PaseoApi) {}
getState = () => this.state;
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
private publish(patch: Partial<State>) {
this.state = { ...this.state, ...patch };
for (const listener of this.listeners) listener();
}
start = () => {
if (!this.connected) {
this.publish({
load: { status: "error", error: "Connect to this daemon to load its hosts." },
});
return;
}
const generation = ++this.generation;
void this.observation?.release();
this.publish({ load: { status: "loading" } });
try {
const observation = this.api.hosts.observe();
this.observation = observation;
observation.subscribe({
snapshot: (directory) => {
if (generation === this.generation)
this.publish({ load: { status: "loaded", directory } });
},
update: (message) => {
if (generation === this.generation && message.type === "hosts.changed")
this.publish({ load: { status: "loaded", directory: message.payload.directory } });
},
error: (error) => {
if (generation === this.generation)
this.publish({ load: { status: "error", error: describe(error) } });
},
});
void observation.ready.catch((error) => {
if (generation === this.generation)
this.publish({ load: { status: "error", error: describe(error) } });
});
} catch (error) {
this.publish({ load: { status: "error", error: describe(error) } });
}
};
close = () => {
this.generation++;
void this.observation?.release();
void this.api.dispose();
this.listeners.clear();
};
edit = (host: HostSummary | null) => {
if (this.state.pending) return;
this.publish({
error: null,
editor: {
key: ++this.editorKey,
host,
connection: "",
label: host?.label ?? "",
metadata: JSON.stringify(host?.metadata ?? {}, null, 2),
replaceIdentity: false,
},
});
};
add = () => this.edit(null);
setLabel = (label: string) => this.set({ label });
setConnection = (connection: string) => this.set({ connection });
setMetadata = (metadata: string) => this.set({ metadata });
setReplaceIdentity = (replaceIdentity: boolean) => this.set({ replaceIdentity });
set = (patch: Partial<Omit<Editor, "host" | "key">>) => {
if (this.state.editor) this.publish({ editor: { ...this.state.editor, ...patch } });
};
cancel = () => {
if (!this.state.pending) this.publish({ editor: null, error: null });
};
dismissError = () => this.publish({ error: null });
private async mutate(id: string, operation: () => Promise<unknown>): Promise<void> {
if (this.state.pending) return;
this.publish({ pending: id, error: null });
try {
await operation();
this.publish({
load: { status: "loaded", directory: await this.api.hosts.list() },
editor: null,
});
} catch (error) {
this.publish({ error: describe(error) });
} finally {
this.publish({ pending: null });
}
}
save = () =>
this.mutate("save", async () => {
const editor = this.state.editor;
if (!editor) return;
let metadata: Record<string, string>;
try {
const value: unknown = JSON.parse(editor.metadata);
if (
!value ||
typeof value !== "object" ||
Array.isArray(value) ||
Object.values(value).some((entry) => typeof entry !== "string")
)
throw new Error("invalid");
metadata = value as Record<string, string>;
} catch {
throw new Error("Metadata must be a JSON object with string values.");
}
if (editor.host) {
const patch: HostUpdate = {
label: editor.label.trim() || null,
replaceIdentity: editor.replaceIdentity,
};
if (JSON.stringify(metadata) !== JSON.stringify(editor.host.metadata))
patch.metadata = metadata;
if (editor.connection.trim()) {
patch.connection = editor.connection.trim();
}
await this.api.hosts.update(editor.host.registrationId, patch);
} else
await this.api.hosts.add({
connection: editor.connection.trim(),
label: editor.label.trim() || undefined,
metadata,
});
});
setEnabled = (host: HostSummary) =>
this.mutate(host.registrationId, () =>
this.api.hosts.update(host.registrationId, { enabled: !host.enabled }),
);
remove = (host: HostSummary) =>
this.mutate(host.registrationId, () => this.api.hosts.remove(host.registrationId));
}
function describe(error: unknown): string {
if (
error &&
typeof error === "object" &&
"userMessage" in error &&
typeof error.userMessage === "string"
)
return error.userMessage;
return error instanceof Error ? error.message : "Host operation failed. Retry when connected.";
}
@@ -1,4 +1,3 @@
import { ConnectedHostsSection } from "@/connected-hosts";
import {
ArrowDown,
ArrowUp,
@@ -375,7 +374,6 @@ export function HostSettingsPage({
<HostConnectionError serverId={serverId} />
<HostAppearanceSection host={host} />
<ConnectedHostsSection serverId={serverId} />
{isLocalDaemon ? <LocalDaemonSection /> : null}