mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 09:42:53 +00:00
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:
@@ -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();
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user