feat(ssh): rework remote instance setup and fix remote installs
Adding an SSH connection now starts from the hosts in the SSH config instead of a blank command field, ports and install options sit behind Advanced settings, and each connection reports one of three states with the failure text and an action that resolves it. Remote installs no longer touch the root-owned global npm prefix: npm is pinned to a prefix under $HOME and bun is resolved at its known location, because an SSH login shell exposes neither on PATH. The opencode CLI is resolved the same way and handed to the remote server through OPENCODE_BINARY, and the server is started and stopped through the resolved binary rather than PATH — the HTTP shutdown route sits behind UI authentication and never stopped anything. A managed remote server can also be published to the remote machine's own network. That requires a UI password, enforced in the form and again in the SSH manager.
This commit is contained in:
@@ -23,7 +23,9 @@ import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLay
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsGroupTitle,
|
||||
SettingsChipGroup,
|
||||
SETTINGS_PAGE_TITLE_CLASS,
|
||||
SETTINGS_SECTION_TITLE_CLASS,
|
||||
SETTINGS_FIELD_LABEL_CLASS,
|
||||
SETTINGS_SELECT_SIZE,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
@@ -150,6 +152,58 @@ const isConnectingPhase = (phase?: string): boolean => {
|
||||
return Boolean(phase && CONNECTING_PHASES.has(phase));
|
||||
};
|
||||
|
||||
// The backend reports 13 lifecycle phases. People only need to know which of
|
||||
// three situations they are in; the phase stays as the secondary detail line.
|
||||
type InstanceState = 'idle' | 'connecting' | 'ready' | 'error';
|
||||
|
||||
const instanceState = (phase?: string): InstanceState => {
|
||||
if (phase === 'ready') return 'ready';
|
||||
if (phase === 'error') return 'error';
|
||||
if (phase === 'degraded' || isConnectingPhase(phase)) return 'connecting';
|
||||
return 'idle';
|
||||
};
|
||||
|
||||
const instanceStateLabelKey = (state: InstanceState): I18nKey => {
|
||||
switch (state) {
|
||||
case 'ready':
|
||||
return 'settings.remoteInstances.page.state.ready';
|
||||
case 'connecting':
|
||||
return 'settings.remoteInstances.page.state.connecting';
|
||||
case 'error':
|
||||
return 'settings.remoteInstances.page.state.problem';
|
||||
default:
|
||||
return 'settings.remoteInstances.page.state.notConnected';
|
||||
}
|
||||
};
|
||||
|
||||
// Known backend failures that the user can act on from here. Everything else
|
||||
// falls back to the raw detail plus the logs button.
|
||||
type ErrorRemedy = 'uiPassword' | 'localPort' | 'noRuntime' | 'noOpencode' | 'externalPort' | null;
|
||||
|
||||
const errorRemedy = (detail?: string): ErrorRemedy => {
|
||||
const text = (detail || '').toLowerCase();
|
||||
if (!text) return null;
|
||||
if (text.includes('ui authentication') || text.includes('ui password')) return 'uiPassword';
|
||||
if (text.includes('already in use') || text.includes('eaddrinuse')) return 'localPort';
|
||||
if (text.includes('neither bun nor npm')) return 'noRuntime';
|
||||
if (text.includes('opencode cli is not installed')) return 'noOpencode';
|
||||
if (text.includes('requires a ui password')) return 'uiPassword';
|
||||
if (text.includes('preferred remote openchamber port')) return 'externalPort';
|
||||
return null;
|
||||
};
|
||||
|
||||
// Remedies the user resolves on the remote machine: explain, do not offer a button.
|
||||
const REMEDY_HINT_KEYS = {
|
||||
noRuntime: 'settings.remoteInstances.page.error.hint.noRuntime',
|
||||
noOpencode: 'settings.remoteInstances.page.error.hint.noOpencode',
|
||||
} satisfies Record<string, I18nKey>;
|
||||
|
||||
const remedyHintKey = (remedy: ErrorRemedy): I18nKey | null => {
|
||||
if (remedy === 'noRuntime') return REMEDY_HINT_KEYS.noRuntime;
|
||||
if (remedy === 'noOpencode') return REMEDY_HINT_KEYS.noOpencode;
|
||||
return null;
|
||||
};
|
||||
|
||||
const phaseDotClass = (phase?: string): string => {
|
||||
if (phase === 'ready') {
|
||||
return 'bg-[var(--status-success)] animate-pulse';
|
||||
@@ -462,8 +516,11 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
const [transportOptions, setTransportOptions] = React.useState<{ localUrl: string | null; lanUrl: string | null; relayAvailable: boolean } | null>(null);
|
||||
const revokedClientCount = React.useMemo(() => remoteClients.filter((client) => Boolean(client.revokedAt)).length, [remoteClients]);
|
||||
const [sshAddDialogOpen, setSshAddDialogOpen] = React.useState(false);
|
||||
const [sshCommandDraft, setSshCommandDraft] = React.useState('ssh user@example.com');
|
||||
const [sshAddMode, setSshAddMode] = React.useState<'saved' | 'manual'>('saved');
|
||||
const [sshHostSearch, setSshHostSearch] = React.useState('');
|
||||
const [sshCommandDraft, setSshCommandDraft] = React.useState('');
|
||||
const [sshNameDraft, setSshNameDraft] = React.useState('');
|
||||
const [advancedOpen, setAdvancedOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
@@ -730,7 +787,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
await createFromCommand(id, command, sshNameDraft.trim() || t('settings.remoteInstances.sidebar.newSshInstanceName'));
|
||||
setSelectedId(id);
|
||||
setSshAddDialogOpen(false);
|
||||
setSshCommandDraft('ssh user@example.com');
|
||||
setSshCommandDraft('');
|
||||
setSshNameDraft('');
|
||||
toast.success(t('settings.remoteInstances.page.toast.instanceCreated'));
|
||||
} catch (error) {
|
||||
@@ -740,6 +797,12 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}
|
||||
}, [createFromCommand, setSelectedId, sshCommandDraft, sshNameDraft, t]);
|
||||
|
||||
const openSshAddDialog = React.useCallback(() => {
|
||||
setSshHostSearch('');
|
||||
setSshAddMode(importCandidates.length > 0 ? 'saved' : 'manual');
|
||||
setSshAddDialogOpen(true);
|
||||
}, [importCandidates.length]);
|
||||
|
||||
const setDefaultDirectHost = React.useCallback(async (id: string) => {
|
||||
await persistDirectHosts(directHosts, id);
|
||||
}, [directHosts, persistDirectHosts]);
|
||||
@@ -1000,6 +1063,11 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
setDraft(selectedInstance);
|
||||
}, [selectedInstance]);
|
||||
|
||||
// Every instance opens on the simple view; advanced stays a deliberate choice.
|
||||
React.useEffect(() => {
|
||||
setAdvancedOpen(false);
|
||||
}, [selectedId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedId) {
|
||||
return;
|
||||
@@ -1064,6 +1132,9 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
const canDisconnect = isReady || isBusy;
|
||||
const statusAgeMs = status ? Math.max(0, clockMs - status.updatedAtMs) : 0;
|
||||
const reconnectAppearsStuck = isReconnecting && statusAgeMs > 12_000;
|
||||
const currentState = instanceState(statusPhase);
|
||||
const currentRemedy = currentState === 'error' ? errorRemedy(status?.detail) : null;
|
||||
const currentRemedyHintKey = remedyHintKey(currentRemedy);
|
||||
|
||||
const hasChanges = React.useMemo(() => {
|
||||
if (!draft || !selectedInstance) return false;
|
||||
@@ -1083,6 +1154,25 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// "Already running" cannot pick a port on its own; catching it here keeps
|
||||
// the failure in the form instead of surfacing it mid-connect.
|
||||
if (normalized.remoteOpenchamber.mode === 'external' && !normalized.remoteOpenchamber.preferredPort) {
|
||||
toast.error(t('settings.remoteInstances.page.validation.externalPortRequired'));
|
||||
setAdvancedOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.remoteOpenchamber.mode === 'managed' &&
|
||||
normalized.remoteOpenchamber.bindHost === '0.0.0.0' &&
|
||||
!normalized.auth.openchamberPassword?.value?.trim()
|
||||
) {
|
||||
toast.error(t('settings.remoteInstances.page.validation.remoteLanNeedsPassword'));
|
||||
setAdvancedOpen(true);
|
||||
window.setTimeout(() => uiPasswordRef.current?.focus(), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalized.localForward.bindHost === '0.0.0.0') {
|
||||
const allow = window.confirm(
|
||||
t('settings.remoteInstances.page.confirm.bindAllInterfaces'),
|
||||
@@ -1154,6 +1244,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
const handleImportCandidate = React.useCallback(
|
||||
(host: string, pattern: boolean) => {
|
||||
setSshAddDialogOpen(false);
|
||||
if (pattern) {
|
||||
setPatternHost(host);
|
||||
setPatternDestination(suggestConcreteHost(host));
|
||||
@@ -1164,6 +1255,25 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
[createImportedInstance],
|
||||
);
|
||||
|
||||
const filteredImportCandidates = React.useMemo(() => {
|
||||
const query = sshHostSearch.trim().toLowerCase();
|
||||
if (!query) return importCandidates;
|
||||
return importCandidates.filter((candidate) => {
|
||||
return candidate.host.toLowerCase().includes(query) || candidate.sshCommand.toLowerCase().includes(query);
|
||||
});
|
||||
}, [importCandidates, sshHostSearch]);
|
||||
|
||||
// Opening a ready instance means pointing this window at the forwarded local
|
||||
// URL — the same navigation the host switcher performs after its own connect.
|
||||
const openInstanceUrl = React.useCallback((localUrl?: string) => {
|
||||
const target = (localUrl || '').trim();
|
||||
if (!target) {
|
||||
toast.error(t('settings.remoteInstances.page.toast.instanceUrlUnavailable'));
|
||||
return;
|
||||
}
|
||||
navigateToUrl(target);
|
||||
}, [t]);
|
||||
|
||||
const handlePatternCreate = React.useCallback(async () => {
|
||||
const host = patternHost;
|
||||
const destination = patternDestination.trim();
|
||||
@@ -1216,6 +1326,44 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}
|
||||
}, [connect, selectedInstance, t, upsertInstance]);
|
||||
|
||||
const uiPasswordRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const remotePortRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Turn a reported failure into the one action that resolves it, instead of
|
||||
// leaving the raw backend sentence as the whole answer.
|
||||
const applyErrorRemedy = React.useCallback(async (remedy: ErrorRemedy) => {
|
||||
if (!selectedInstance) return;
|
||||
|
||||
if (remedy === 'localPort') {
|
||||
const nextInstance: DesktopSshInstance = {
|
||||
...selectedInstance,
|
||||
localForward: {
|
||||
...selectedInstance.localForward,
|
||||
preferredLocalPort: randomPort(),
|
||||
},
|
||||
};
|
||||
try {
|
||||
await upsertInstance(nextInstance);
|
||||
await connect(nextInstance.id);
|
||||
toast.success(t('settings.remoteInstances.sidebar.toast.retriedWithRandomPort'));
|
||||
} catch (error) {
|
||||
toast.error(t('settings.remoteInstances.page.toast.connectFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setAdvancedOpen(true);
|
||||
window.setTimeout(() => {
|
||||
if (remedy === 'uiPassword') {
|
||||
uiPasswordRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
remotePortRef.current?.scrollIntoView({ block: 'center' });
|
||||
}, 0);
|
||||
}, [connect, selectedInstance, t, upsertInstance]);
|
||||
|
||||
const readLogsForInstance = React.useCallback(async (id: string) => {
|
||||
const lines = await desktopSshLogs(id, 600);
|
||||
return lines.map((line) => formatLogLine(line));
|
||||
@@ -1321,6 +1469,24 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canDisconnect && draft.remoteOpenchamber.mode === 'external' && !draft.remoteOpenchamber.preferredPort) {
|
||||
toast.error(t('settings.remoteInstances.page.validation.externalPortRequired'));
|
||||
setAdvancedOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!canDisconnect &&
|
||||
draft.remoteOpenchamber.mode === 'managed' &&
|
||||
draft.remoteOpenchamber.bindHost === '0.0.0.0' &&
|
||||
!draft.auth.openchamberPassword?.value?.trim()
|
||||
) {
|
||||
toast.error(t('settings.remoteInstances.page.validation.remoteLanNeedsPassword'));
|
||||
setAdvancedOpen(true);
|
||||
window.setTimeout(() => uiPasswordRef.current?.focus(), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPrimaryActionPending(true);
|
||||
const operation = canDisconnect ? disconnect(draft.id) : connectWithPortRecovery();
|
||||
void operation
|
||||
@@ -1774,7 +1940,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
title={t('settings.remoteInstances.sidebar.title')}
|
||||
description={t('settings.remoteInstances.sidebar.total', { count: instances.length })}
|
||||
headerAction={(
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(true)}>
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={openSshAddDialog}>
|
||||
<Icon name="add" className="h-3.5 w-3.5" />
|
||||
{t('settings.remoteInstances.sidebar.actions.addSshInstance')}
|
||||
</Button>
|
||||
@@ -1782,50 +1948,71 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
contentClassName="space-y-2.5"
|
||||
>
|
||||
{isLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.state.loadingInstances')}</p>
|
||||
) : instances.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.noneFound')}</p>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{importCandidates.length === 1
|
||||
? t('settings.remoteInstances.page.empty.noInstancesWithOneImport')
|
||||
: importCandidates.length > 1
|
||||
? t('settings.remoteInstances.page.empty.noInstancesWithImports', { count: importCandidates.length })
|
||||
: t('settings.remoteInstances.page.empty.noInstances')}
|
||||
</p>
|
||||
) : instances.map((instance) => {
|
||||
const instanceStatus = statusesById[instance.id];
|
||||
const title = instance.nickname?.trim() || instance.sshParsed?.destination || instance.id;
|
||||
const phase = instanceStatus?.phase;
|
||||
const ready = phase === 'ready';
|
||||
const state = instanceState(phase);
|
||||
const failureDetail = state === 'error' ? instanceStatus?.detail : undefined;
|
||||
return (
|
||||
<div key={instance.id} className="flex items-center justify-between gap-3 py-1.5">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${phaseDotClass(phase)}`} />
|
||||
<p className="typography-ui-label text-foreground truncate">{title}</p>
|
||||
<div key={instance.id} className="space-y-1.5 py-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${phaseDotClass(phase)}`} />
|
||||
<p className="typography-ui-label text-foreground truncate">{title}</p>
|
||||
</div>
|
||||
<p className="typography-micro text-muted-foreground truncate">
|
||||
{t(instanceStateLabelKey(state))}
|
||||
{state === 'connecting' ? ` · ${t(phaseLabelKey(phase))}` : ''}
|
||||
{ready && instanceStatus?.localUrl ? ` · ${instanceStatus.localUrl}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{ready ? (
|
||||
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => openInstanceUrl(instanceStatus?.localUrl)}>
|
||||
<Icon name="external-link" className="h-3.5 w-3.5" />
|
||||
{t('settings.remoteInstances.page.actions.open')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => {
|
||||
const op = ready ? disconnect(instance.id) : connect(instance.id);
|
||||
void op.catch((err) => toast.error(ready ? t('settings.remoteInstances.sidebar.toast.disconnectFailed') : t('settings.remoteInstances.sidebar.toast.connectFailed'), {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
}));
|
||||
}}>
|
||||
{ready ? <Icon name="stop" className="h-3.5 w-3.5" /> : <Icon name="plug-2" className="h-3.5 w-3.5" />}
|
||||
{ready ? t('settings.remoteInstances.sidebar.actions.disconnect') : t('settings.remoteInstances.sidebar.actions.connect')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => setSelectedId(instance.id)}>
|
||||
<Icon name="pencil" className="h-3.5 w-3.5" />
|
||||
{t('desktopHostSwitcher.actions.edit')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => {
|
||||
const ok = window.confirm(t('settings.remoteInstances.page.confirm.removeInstance'));
|
||||
if (!ok) return;
|
||||
void removeInstance(instance.id).catch((err) => toast.error(t('settings.remoteInstances.page.toast.removeInstanceFailed'), {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
}));
|
||||
}}>
|
||||
<Icon name="delete-bin" className="h-3.5 w-3.5" />
|
||||
{t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="typography-micro text-muted-foreground truncate">
|
||||
{t(phaseLabelKey(phase))}{instanceStatus?.localUrl ? ` · ${instanceStatus.localUrl}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => {
|
||||
const op = ready ? disconnect(instance.id) : connect(instance.id);
|
||||
void op.catch((err) => toast.error(ready ? t('settings.remoteInstances.sidebar.toast.disconnectFailed') : t('settings.remoteInstances.sidebar.toast.connectFailed'), {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
}));
|
||||
}}>
|
||||
{ready ? <Icon name="stop" className="h-3.5 w-3.5" /> : <Icon name="plug-2" className="h-3.5 w-3.5" />}
|
||||
{ready ? t('settings.remoteInstances.sidebar.actions.disconnect') : t('settings.remoteInstances.sidebar.actions.connect')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => setSelectedId(instance.id)}>
|
||||
<Icon name="pencil" className="h-3.5 w-3.5" />
|
||||
{t('desktopHostSwitcher.actions.edit')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => {
|
||||
const ok = window.confirm(t('settings.remoteInstances.page.confirm.removeInstance'));
|
||||
if (!ok) return;
|
||||
void removeInstance(instance.id).catch((err) => toast.error(t('settings.remoteInstances.page.toast.removeInstanceFailed'), {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
}));
|
||||
}}>
|
||||
<Icon name="delete-bin" className="h-3.5 w-3.5" />
|
||||
{t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
{failureDetail ? (
|
||||
<p className="typography-micro text-[var(--status-error)] break-words">{failureDetail}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -1835,52 +2022,70 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.remoteInstances.sidebar.actions.addSshInstance')}</DialogTitle>
|
||||
<DialogDescription>{t('settings.remoteInstances.page.section.instanceDescription')}</DialogDescription>
|
||||
<DialogDescription>{t('settings.remoteInstances.page.addDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); void createSshInstanceFromDialog(); }}>
|
||||
<Input className="h-8" value={sshNameDraft} onChange={(event) => setSshNameDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')} disabled={isSaving} />
|
||||
<Input className="h-8" value={sshCommandDraft} onChange={(event) => setSshCommandDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.sshCommandPlaceholder')} disabled={isSaving} autoFocus />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(false)} disabled={isSaving}>{t('settings.common.actions.cancel')}</Button>
|
||||
<Button type="submit" size="xs" className="!font-normal" disabled={isSaving || !sshCommandDraft.trim()}>{t('settings.common.actions.create')}</Button>
|
||||
<SettingsChipGroup
|
||||
value={sshAddMode}
|
||||
onChange={setSshAddMode}
|
||||
aria-label={t('settings.remoteInstances.page.addDialog.sourceLabel')}
|
||||
options={[
|
||||
{ value: 'saved', label: t('settings.remoteInstances.page.addDialog.tab.saved') },
|
||||
{ value: 'manual', label: t('settings.remoteInstances.page.addDialog.tab.manual') },
|
||||
]}
|
||||
/>
|
||||
{sshAddMode === 'saved' ? (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
className="h-8"
|
||||
value={sshHostSearch}
|
||||
onChange={(event) => setSshHostSearch(event.target.value)}
|
||||
placeholder={t('settings.remoteInstances.page.addDialog.searchPlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
{isImportsLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
|
||||
) : importCandidates.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.addDialog.emptySaved')}</p>
|
||||
) : filteredImportCandidates.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.addDialog.searchEmpty')}</p>
|
||||
) : (
|
||||
<div className="max-h-[45vh] overflow-auto">
|
||||
{filteredImportCandidates.map((candidate) => (
|
||||
<div key={`${candidate.source}:${candidate.host}`} className="flex items-center justify-between gap-3 border-b border-[var(--surface-subtle)] py-2.5 last:border-b-0">
|
||||
<div className="min-w-0">
|
||||
<div className="typography-ui-label font-medium text-foreground truncate">
|
||||
{candidate.host}
|
||||
{candidate.pattern ? ` ${t('settings.remoteInstances.page.import.patternSuffix')}` : ''}
|
||||
</div>
|
||||
<div className="typography-meta text-muted-foreground truncate">{candidate.sshCommand}</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
|
||||
>
|
||||
{t('settings.remoteInstances.page.addDialog.use')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); void createSshInstanceFromDialog(); }}>
|
||||
<Input className="h-8" value={sshNameDraft} onChange={(event) => setSshNameDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')} disabled={isSaving} />
|
||||
<Input className="h-8" value={sshCommandDraft} onChange={(event) => setSshCommandDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.sshCommandPlaceholder')} disabled={isSaving} autoFocus />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(false)} disabled={isSaving}>{t('settings.common.actions.cancel')}</Button>
|
||||
<Button type="submit" size="xs" className="!font-normal" disabled={isSaving || !sshCommandDraft.trim()}>{t('settings.common.actions.create')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog> : null}
|
||||
|
||||
{showInstanceManagement ? <SettingsSection
|
||||
title={t('settings.remoteInstances.page.import.sectionTitle')}
|
||||
>
|
||||
{isImportsLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
|
||||
) : importCandidates.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.noneFound')}</p>
|
||||
) : (
|
||||
<div>
|
||||
{importCandidates.map((candidate) => (
|
||||
<div key={`${candidate.source}:${candidate.host}`} className="flex items-center justify-between gap-3 border-b border-[var(--surface-subtle)] py-3 last:border-b-0">
|
||||
<div className="min-w-0">
|
||||
<div className="typography-ui-label font-medium text-foreground truncate">
|
||||
{candidate.host}
|
||||
{candidate.pattern ? ` ${t('settings.remoteInstances.page.import.patternSuffix')}` : ''}
|
||||
</div>
|
||||
<div className="typography-meta text-muted-foreground truncate">{candidate.sshCommand}</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
|
||||
>
|
||||
{t('settings.common.actions.import')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection> : null}
|
||||
|
||||
<Dialog
|
||||
open={Boolean(patternHost)}
|
||||
onOpenChange={(open) => {
|
||||
@@ -1925,6 +2130,10 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}
|
||||
|
||||
const isManagedMode = draft.remoteOpenchamber.mode === 'managed';
|
||||
// Publishing the remote server to its network turns the UI password from an
|
||||
// option into the only thing standing in front of it.
|
||||
const remoteLanExposed = isManagedMode && draft.remoteOpenchamber.bindHost === '0.0.0.0';
|
||||
const uiPasswordMissing = remoteLanExposed && !draft.auth.openchamberPassword?.value?.trim();
|
||||
const instanceTitle = draft.nickname?.trim() || draft.sshParsed?.destination || draft.id;
|
||||
|
||||
return (
|
||||
@@ -1934,7 +2143,8 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<h1 className={`${SETTINGS_PAGE_TITLE_CLASS} truncate`}>{instanceTitle}</h1>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 typography-meta text-muted-foreground">
|
||||
<span className={`h-2.5 w-2.5 rounded-full ${phaseDotClass(statusPhase)}`} />
|
||||
<span>{t(phaseLabelKey(statusPhase))}</span>
|
||||
<span className="text-foreground">{t(instanceStateLabelKey(currentState))}</span>
|
||||
{currentState === 'connecting' ? <span>{t(phaseLabelKey(statusPhase))}</span> : null}
|
||||
{status?.localUrl ? <span className="font-mono text-foreground/80">{status.localUrl}</span> : null}
|
||||
{reconnectAppearsStuck ? <span>{t('settings.remoteInstances.page.status.reconnectStale')}</span> : null}
|
||||
</div>
|
||||
@@ -2005,6 +2215,29 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
{t('settings.remoteInstances.sidebar.actions.remove')}
|
||||
</Button>
|
||||
</div>
|
||||
{currentState === 'error' && status?.detail ? (
|
||||
<div className="space-y-2 rounded-md border border-[var(--status-error)]/30 bg-[var(--status-error-background)] p-3">
|
||||
<p className="typography-meta text-[var(--status-error)] break-words">{status.detail}</p>
|
||||
{currentRemedyHintKey ? (
|
||||
<p className="typography-micro text-muted-foreground">{t(currentRemedyHintKey)}</p>
|
||||
) : null}
|
||||
{currentRemedy && !currentRemedyHintKey ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => void applyErrorRemedy(currentRemedy)}
|
||||
>
|
||||
{currentRemedy === 'uiPassword'
|
||||
? t('settings.remoteInstances.page.error.action.setUiPassword')
|
||||
: currentRemedy === 'localPort'
|
||||
? t('settings.remoteInstances.page.error.action.pickRandomPort')
|
||||
: t('settings.remoteInstances.page.error.action.setRemotePort')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{status?.localUrl ? (
|
||||
<div className="flex flex-wrap items-center gap-2 typography-meta text-muted-foreground">
|
||||
<span>{t('settings.remoteInstances.page.status.currentLocalUrl')}</span>
|
||||
@@ -2046,30 +2279,6 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.connectionTimeoutSeconds')}</span>
|
||||
<NumberInput
|
||||
containerClassName="w-fit"
|
||||
min={5}
|
||||
max={240}
|
||||
step={1}
|
||||
className="w-16 tabular-nums"
|
||||
value={draft.connectionTimeoutSec}
|
||||
onValueChange={(next) => {
|
||||
updateDraft((current) => ({
|
||||
...current,
|
||||
connectionTimeoutSec: Number.isFinite(next) ? next : current.connectionTimeoutSec,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.remoteInstances.page.section.remoteServer')}
|
||||
info={t('settings.remoteInstances.page.section.remoteServerDescription')}
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<div className="w-56 shrink-0">
|
||||
<HintLabel
|
||||
@@ -2099,8 +2308,40 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
</SettingsSection>
|
||||
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger className="mt-6 w-auto justify-start gap-1.5">
|
||||
<span className={SETTINGS_SECTION_TITLE_CLASS}>{t('settings.remoteInstances.page.section.advanced')}</span>
|
||||
<Icon name={advancedOpen ? 'arrow-up-s' : 'arrow-down-s'} className="h-4 w-4 text-muted-foreground" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<p className="px-2 pb-2 typography-micro text-muted-foreground">{t('settings.remoteInstances.page.section.advancedHint')}</p>
|
||||
<div className="flex flex-col gap-1.5 px-2 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.connectionTimeoutSeconds')}</span>
|
||||
<NumberInput
|
||||
containerClassName="w-fit"
|
||||
min={5}
|
||||
max={240}
|
||||
step={1}
|
||||
className="w-16 tabular-nums"
|
||||
value={draft.connectionTimeoutSec}
|
||||
onValueChange={(next) => {
|
||||
updateDraft((current) => ({
|
||||
...current,
|
||||
connectionTimeoutSec: Number.isFinite(next) ? next : current.connectionTimeoutSec,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.remoteInstances.page.section.remoteServer')}
|
||||
info={t('settings.remoteInstances.page.section.remoteServerDescription')}
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<div className="w-56 shrink-0">
|
||||
<div className="w-56 shrink-0" ref={remotePortRef}>
|
||||
<HintLabel
|
||||
label={t('settings.remoteInstances.page.field.preferredRemotePort')}
|
||||
hint={t('settings.remoteInstances.page.field.preferredRemotePortHint')}
|
||||
@@ -2150,10 +2391,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
...current,
|
||||
remoteOpenchamber: {
|
||||
...current.remoteOpenchamber,
|
||||
installMethod:
|
||||
value === 'npm' || value === 'download_release' || value === 'upload_bundle'
|
||||
? value
|
||||
: 'bun',
|
||||
installMethod: value === 'npm' || value === 'bun' ? value : 'auto',
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -2162,15 +2400,45 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<SelectValue placeholder={t('settings.remoteInstances.page.field.selectInstallMethodPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t('settings.remoteInstances.page.field.installMethodAuto')}</SelectItem>
|
||||
<SelectItem value="bun">bun</SelectItem>
|
||||
<SelectItem value="npm">npm</SelectItem>
|
||||
<SelectItem value="download_release">{t('settings.remoteInstances.page.field.installMethodDownloadRelease')}</SelectItem>
|
||||
<SelectItem value="upload_bundle">{t('settings.remoteInstances.page.field.installMethodUploadBundle')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isManagedMode ? (
|
||||
<div className="py-1.5">
|
||||
<div className="flex flex-col gap-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<div className="w-56 shrink-0">
|
||||
<HintLabel
|
||||
label={t('settings.remoteInstances.page.field.remoteLanAccess')}
|
||||
hint={t('settings.remoteInstances.page.field.remoteLanAccessHint')}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
checked={remoteLanExposed}
|
||||
onCheckedChange={(checked) =>
|
||||
updateDraft((current) => ({
|
||||
...current,
|
||||
remoteOpenchamber: {
|
||||
...current.remoteOpenchamber,
|
||||
bindHost: checked ? '0.0.0.0' : '127.0.0.1',
|
||||
},
|
||||
}))
|
||||
}
|
||||
aria-label={t('settings.remoteInstances.page.field.remoteLanAccess')}
|
||||
/>
|
||||
</div>
|
||||
{remoteLanExposed ? (
|
||||
<p className="mt-2 typography-micro text-[var(--status-warning)] md:pl-[16rem]">
|
||||
{t('settings.remoteInstances.page.field.remoteLanAccessWarning')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isManagedMode ? (
|
||||
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<div className="w-56 shrink-0">
|
||||
@@ -2227,13 +2495,13 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit min-w-[140px]">
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit min-w-[240px]">
|
||||
<SelectValue placeholder={t('settings.remoteInstances.page.field.selectBindHostPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="127.0.0.1">127.0.0.1</SelectItem>
|
||||
<SelectItem value="localhost">localhost</SelectItem>
|
||||
<SelectItem value="0.0.0.0">0.0.0.0</SelectItem>
|
||||
<SelectItem value="127.0.0.1">{t('settings.remoteInstances.page.field.bindHostOption.loopback')}</SelectItem>
|
||||
<SelectItem value="localhost">{t('settings.remoteInstances.page.field.bindHostOption.localhost')}</SelectItem>
|
||||
<SelectItem value="0.0.0.0">{t('settings.remoteInstances.page.field.bindHostOption.lan')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -2293,6 +2561,13 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 pt-1">
|
||||
<p className="typography-micro text-muted-foreground">{t('settings.remoteInstances.page.tunnelPreview.caption')}</p>
|
||||
<p className="typography-micro font-mono text-foreground/80 break-all">
|
||||
{`${draft.localForward.bindHost}:${draft.localForward.preferredLocalPort || 'auto'} → ${draft.sshParsed?.destination || draft.nickname || 'remote'}:${draft.remoteOpenchamber.preferredPort || 'auto'}`}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
@@ -2301,7 +2576,12 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.sshPasswordOptional')}</span>
|
||||
<div className="w-56 shrink-0">
|
||||
<HintLabel
|
||||
label={t('settings.remoteInstances.page.field.sshPasswordOptional')}
|
||||
hint={t('settings.remoteInstances.page.field.sshPasswordHint')}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
className="h-7 md:max-w-sm"
|
||||
type="password"
|
||||
@@ -2324,10 +2604,21 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.uiPasswordOptional')}</span>
|
||||
<div className="w-56 shrink-0">
|
||||
<HintLabel
|
||||
label={remoteLanExposed
|
||||
? t('settings.remoteInstances.page.field.uiPasswordRequired')
|
||||
: t('settings.remoteInstances.page.field.uiPasswordOptional')}
|
||||
hint={isManagedMode
|
||||
? t('settings.remoteInstances.page.field.uiPasswordHintManaged')
|
||||
: t('settings.remoteInstances.page.field.uiPasswordHintExternal')}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
className="h-7 md:max-w-sm"
|
||||
className={cn('h-7 md:max-w-sm', uiPasswordMissing && 'border-[var(--status-error)]')}
|
||||
type="password"
|
||||
ref={uiPasswordRef}
|
||||
aria-invalid={uiPasswordMissing}
|
||||
value={draft.auth.openchamberPassword?.value || ''}
|
||||
onChange={(event) =>
|
||||
updateDraft((current) => ({
|
||||
@@ -2345,6 +2636,11 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
placeholder={t('settings.remoteInstances.page.field.uiPasswordPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
{uiPasswordMissing ? (
|
||||
<p className="typography-micro text-[var(--status-error)] md:pl-[16rem]">
|
||||
{t('settings.remoteInstances.page.field.uiPasswordMissingForLan')}
|
||||
</p>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
@@ -2621,6 +2917,9 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<div className="mt-8 border-t border-[var(--interactive-border)] pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={() => void handleSave()} disabled={!hasChanges || isSaving}>
|
||||
|
||||
Reference in New Issue
Block a user