New Connections Configs (#1383)

This adds the following connections changes:
- connections can be hidden from the dropdown in our internal
connections.json config
- `wsh ssh` -i will write identity files to the internal
connections.json config for that connection
- the internal connections.json config will also be used to get identity
files when connecting
- the internal connections.json config allows setting theme, fontsize,
and font for specific connections
- successful connections (including those using wsh ssh) are saved to
the internal connections.json config
- the connections.json config will be used to help pre-populate the
dropdown list
- adds an item to the dropdown to edit the connections config in an
ephemeral block

---------

Co-authored-by: Evan Simkowitz <esimkowitz@users.noreply.github.com>
This commit is contained in:
Sylvie Crowe 2024-12-05 10:02:07 -08:00 committed by GitHub
parent 5c315779ba
commit b4b0222c9d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 192 additions and 25 deletions

View File

@ -15,6 +15,8 @@ import { TypeAheadModal } from "@/app/modals/typeaheadmodal";
import { ContextMenuModel } from "@/app/store/contextmenu";
import {
atoms,
createBlock,
getApi,
getBlockComponentModel,
getConnStatusAtom,
getHostName,
@ -182,6 +184,9 @@ const BlockFrame_Header = ({
const prevMagifiedState = React.useRef(magnified);
const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection);
const dragHandleRef = preview ? null : nodeModel.dragHandleRef;
const connName = blockData?.meta?.connection;
const allSettings = jotai.useAtomValue(atoms.fullConfigAtom);
const wshEnabled = allSettings?.connections?.[connName]?.["conn:wshenabled"] ?? true;
React.useEffect(() => {
if (!magnified || preview || prevMagifiedState.current) {
@ -239,6 +244,11 @@ const BlockFrame_Header = ({
</div>
);
}
const wshInstallButton: IconButtonDecl = {
elemtype: "iconbutton",
icon: "link-slash",
title: "wsh is not installed for this connection",
};
return (
<div className="block-frame-default-header" ref={dragHandleRef} onContextMenu={onContextMenu}>
@ -256,6 +266,9 @@ const BlockFrame_Header = ({
changeConnModalAtom={changeConnModalAtom}
/>
)}
{manageConnection && !wshEnabled && (
<IconButton decl={wshInstallButton} className="block-frame-header-iconbutton" />
)}
<div className="block-frame-textelems-wrapper">{headerTextElems}</div>
<div className="block-frame-end-icons">{endIconsElem}</div>
</div>
@ -568,6 +581,10 @@ const ChangeConnectionBlockModal = React.memo(
const allConnStatus = jotai.useAtomValue(atoms.allConnStatus);
const [rowIndex, setRowIndex] = React.useState(0);
const connStatusMap = new Map<string, ConnStatus>();
const fullConfig = jotai.useAtomValue(atoms.fullConfigAtom);
const connectionsConfig = fullConfig.connections;
let filterOutNowsh = util.useAtomValueSafe(viewModel.filterOutNowsh) || true;
let maxActiveConnNum = 1;
for (const conn of allConnStatus) {
if (conn.activeconnnum > maxActiveConnNum) {
@ -638,7 +655,12 @@ const ChangeConnectionBlockModal = React.memo(
if (conn === connSelected) {
createNew = false;
}
if (conn.includes(connSelected)) {
if (
conn.includes(connSelected) &&
connectionsConfig[conn]?.["display:hidden"] != true &&
(connectionsConfig[conn]?.["conn:wshenabled"] != false || !filterOutNowsh)
// != false is necessary because of defaults
) {
filteredList.push(conn);
}
}
@ -647,7 +669,12 @@ const ChangeConnectionBlockModal = React.memo(
if (conn === connSelected) {
createNew = false;
}
if (conn.includes(connSelected)) {
if (
conn.includes(connSelected) &&
connectionsConfig[conn]?.["display:hidden"] != true &&
(connectionsConfig[conn]?.["conn:wshenabled"] != false || !filterOutNowsh)
// != false is necessary because of defaults
) {
filteredWslList.push(conn);
}
}
@ -734,9 +761,38 @@ const ChangeConnectionBlockModal = React.memo(
};
return item;
});
const connectionsEditItem: SuggestionConnectionItem = {
status: "disconnected",
icon: "gear",
iconColor: "var(--grey-text-color",
value: "Edit Connections",
label: "Edit Connections",
onSelect: () => {
util.fireAndForget(async () => {
globalStore.set(changeConnModalAtom, false);
const path = `${getApi().getConfigDir()}/connections.json`;
const blockDef: BlockDef = {
meta: {
view: "preview",
file: path,
},
};
await createBlock(blockDef, false, true);
});
},
};
const sortedRemoteItems = remoteItems.sort(
(itemA: SuggestionConnectionItem, itemB: SuggestionConnectionItem) => {
const connNameA = itemA.value;
const connNameB = itemB.value;
const valueA = connectionsConfig[connNameA]?.["display:order"] ?? 0;
const valueB = connectionsConfig[connNameB]?.["display:order"] ?? 0;
return valueA - valueB;
}
);
const remoteSuggestions: SuggestionConnectionScope = {
headerText: "Remote",
items: remoteItems,
items: [...sortedRemoteItems, connectionsEditItem],
};
let suggestions: Array<SuggestionsType> = [];

View File

@ -246,6 +246,21 @@ function useBlockMetaKeyAtom<T extends keyof MetaType>(blockId: string, key: T):
return useAtomValue(getBlockMetaKeyAtom(blockId, key));
}
function getConnConfigKeyAtom<T extends keyof ConnKeywords>(connName: string, key: T): Atom<ConnKeywords[T]> {
let connCache = getSingleConnAtomCache(connName);
const keyAtomName = "#conn-" + key;
let keyAtom = connCache.get(keyAtomName);
if (keyAtom != null) {
return keyAtom;
}
keyAtom = atom((get) => {
let fullConfig = get(atoms.fullConfigAtom);
return fullConfig.connections[connName]?.[key];
});
connCache.set(keyAtomName, keyAtom);
return keyAtom;
}
const settingsAtomCache = new Map<string, Atom<any>>();
function getOverrideConfigAtom<T extends keyof SettingsType>(blockId: string, key: T): Atom<SettingsType[T]> {
@ -261,6 +276,13 @@ function getOverrideConfigAtom<T extends keyof SettingsType>(blockId: string, ke
if (metaKeyVal != null) {
return metaKeyVal;
}
const connNameAtom = getBlockMetaKeyAtom(blockId, "connection");
const connName = get(connNameAtom);
const connConfigKeyAtom = getConnConfigKeyAtom(connName, key as any);
const connConfigKeyVal = get(connConfigKeyAtom);
if (connConfigKeyVal != null) {
return connConfigKeyVal;
}
const settingsKeyAtom = getSettingsKeyAtom(key);
const settingsVal = get(settingsKeyAtom);
if (settingsVal != null) {
@ -322,6 +344,15 @@ function getSingleBlockAtomCache(blockId: string): Map<string, Atom<any>> {
return blockCache;
}
function getSingleConnAtomCache(connName: string): Map<string, Atom<any>> {
let blockCache = blockAtomCache.get(connName);
if (blockCache == null) {
blockCache = new Map<string, Atom<any>>();
blockAtomCache.set(connName, blockCache);
}
return blockCache;
}
function useBlockAtom<T>(blockId: string, name: string, makeFn: () => Atom<T>): Atom<T> {
const blockCache = getSingleBlockAtomCache(blockId);
let atom = blockCache.get(name);

View File

@ -121,6 +121,7 @@ export class PreviewModel implements ViewModel {
loadableSpecializedView: Atom<Loadable<{ specializedView?: string; errorStr?: string }>>;
manageConnection: Atom<boolean>;
connStatus: Atom<ConnStatus>;
filterOutNowsh?: Atom<boolean>;
metaFilePath: Atom<string>;
statFilePath: Atom<Promise<string>>;
@ -164,6 +165,7 @@ export class PreviewModel implements ViewModel {
this.manageConnection = atom(true);
this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`);
this.markdownShowToc = atom(false);
this.filterOutNowsh = atom(true);
this.monacoRef = createRef();
this.viewIcon = atom((get) => {
const blockData = get(this.blockAtom);

View File

@ -91,7 +91,7 @@ function convertWaveEventToDataItem(event: WaveEvent): DataItem {
return dataItem;
}
class SysinfoViewModel {
class SysinfoViewModel implements ViewModel {
viewType: string;
blockAtom: jotai.Atom<Block>;
termMode: jotai.Atom<string>;
@ -109,6 +109,7 @@ class SysinfoViewModel {
metrics: jotai.Atom<string[]>;
connection: jotai.Atom<string>;
manageConnection: jotai.Atom<boolean>;
filterOutNowsh: jotai.Atom<boolean>;
connStatus: jotai.Atom<ConnStatus>;
plotMetaAtom: jotai.PrimitiveAtom<Map<string, TimeSeriesMeta>>;
endIconButtons: jotai.Atom<IconButtonDecl[]>;
@ -176,6 +177,7 @@ class SysinfoViewModel {
});
this.plotMetaAtom = jotai.atom(new Map(Object.entries(DefaultPlotMeta)));
this.manageConnection = jotai.atom(true);
this.filterOutNowsh = jotai.atom(true);
this.loadingAtom = jotai.atom(true);
this.numPoints = jotai.atom((get) => {
const blockData = get(this.blockAtom);

View File

@ -41,7 +41,7 @@ type InitialLoadDataType = {
heldData: Uint8Array[];
};
class TermViewModel {
class TermViewModel implements ViewModel {
viewType: string;
nodeModel: BlockNodeModel;
connected: boolean;
@ -54,6 +54,7 @@ class TermViewModel {
viewText: jotai.Atom<HeaderElem[]>;
blockBg: jotai.Atom<MetaType>;
manageConnection: jotai.Atom<boolean>;
filterOutNowsh?: jotai.Atom<boolean>;
connStatus: jotai.Atom<ConnStatus>;
termWshClient: TermWshClient;
vdomBlockId: jotai.Atom<string>;
@ -196,6 +197,7 @@ class TermViewModel {
}
return true;
});
this.filterOutNowsh = jotai.atom(false);
this.termThemeNameAtom = useBlockAtom(blockId, "termthemeatom", () => {
return jotai.atom<string>((get) => {
return get(getOverrideConfigAtom(this.blockId, "term:theme")) ?? DefaultTermTheme;
@ -221,7 +223,10 @@ class TermViewModel {
const blockData = get(this.blockAtom);
const fsSettingsAtom = getSettingsKeyAtom("term:fontsize");
const settingsFontSize = get(fsSettingsAtom);
const rtnFontSize = blockData?.meta?.["term:fontsize"] ?? settingsFontSize ?? 12;
const connName = blockData?.meta?.connection;
const fullConfig = get(atoms.fullConfigAtom);
const connFontSize = fullConfig?.connections?.[connName]?.["term:fontsize"];
const rtnFontSize = blockData?.meta?.["term:fontsize"] ?? connFontSize ?? settingsFontSize ?? 12;
if (typeof rtnFontSize != "number" || isNaN(rtnFontSize) || rtnFontSize < 4 || rtnFontSize > 64) {
return 12;
}
@ -725,6 +730,8 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
const termModeRef = React.useRef(termMode);
const termFontSize = jotai.useAtomValue(model.fontSizeAtom);
const fullConfig = globalStore.get(atoms.fullConfigAtom);
const connFontFamily = fullConfig.connections?.[blockData?.meta?.connection]?.["term:fontfamily"];
React.useEffect(() => {
const fullConfig = globalStore.get(atoms.fullConfigAtom);
@ -750,7 +757,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
{
theme: termTheme,
fontSize: termFontSize,
fontFamily: termSettings?.["term:fontfamily"] ?? "Hack",
fontFamily: termSettings?.["term:fontfamily"] ?? connFontFamily ?? "Hack",
drawBoldTextInBrightColors: false,
fontWeight: "normal",
fontWeightBold: "bold",
@ -784,7 +791,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
termWrap.dispose();
rszObs.disconnect();
};
}, [blockId, termSettings, termFontSize]);
}, [blockId, termSettings, termFontSize, connFontFamily]);
React.useEffect(() => {
if (termModeRef.current == "vdom" && termMode == "term") {

View File

@ -237,6 +237,7 @@ declare global {
blockBg?: jotai.Atom<MetaType>;
manageConnection?: jotai.Atom<boolean>;
noPadding?: jotai.Atom<boolean>;
filterOutNowsh?: jotai.Atom<boolean>;
onBack?: () => void;
onForward?: () => void;

View File

@ -277,8 +277,14 @@ declare global {
// wshrpc.ConnKeywords
type ConnKeywords = {
wshenabled?: boolean;
askbeforewshinstall?: boolean;
"conn:wshenabled"?: boolean;
"conn:askbeforewshinstall"?: boolean;
"display:hidden"?: boolean;
"display:order"?: number;
"term:*"?: boolean;
"term:fontsize"?: number;
"term:fontfamily"?: string;
"term:theme"?: string;
"ssh:user"?: string;
"ssh:hostname"?: string;
"ssh:port"?: string;

View File

@ -342,7 +342,7 @@ func (conn *SSHConn) CheckAndInstallWsh(ctx context.Context, clientDisplayName s
}
if !response.Confirm {
meta := make(map[string]any)
meta["wshenabled"] = false
meta["conn:wshenabled"] = false
err = wconfig.SetConnectionsConfigValue(conn.GetName(), meta)
if err != nil {
log.Printf("warning: error writing to connections file: %v", err)
@ -454,7 +454,39 @@ func (conn *SSHConn) Connect(ctx context.Context, connFlags *wshrpc.ConnKeywords
}
})
conn.FireConnChangeEvent()
return err
if err != nil {
return err
}
// logic for saving connection and potential flags (we only save once a connection has been made successfully)
// at the moment, identity files is the only saved flag
var identityFiles []string
existingConfig := wconfig.ReadFullConfig()
existingConnection, ok := existingConfig.Connections[conn.GetName()]
if ok {
identityFiles = existingConnection.SshIdentityFile
}
if err != nil {
// i do not consider this a critical failure
log.Printf("config read error: unable to save connection %s: %v", conn.GetName(), err)
}
meta := make(map[string]any)
if connFlags.SshIdentityFile != nil {
for _, identityFile := range connFlags.SshIdentityFile {
if utilfn.ContainsStr(identityFiles, identityFile) {
continue
}
identityFiles = append(identityFiles, connFlags.SshIdentityFile...)
}
meta["ssh:identityfile"] = identityFiles
}
err = wconfig.SetConnectionsConfigValue(conn.GetName(), meta)
if err != nil {
// i do not consider this a critical failure
log.Printf("config write error: unable to save connection %s: %v", conn.GetName(), err)
}
return nil
}
func (conn *SSHConn) WithLock(fn func()) {
@ -484,11 +516,11 @@ func (conn *SSHConn) connectInternal(ctx context.Context, connFlags *wshrpc.Conn
askBeforeInstall := config.Settings.ConnAskBeforeWshInstall
connSettings, ok := config.Connections[conn.GetName()]
if ok {
if connSettings.WshEnabled != nil {
enableWsh = *connSettings.WshEnabled
if connSettings.ConnWshEnabled != nil {
enableWsh = *connSettings.ConnWshEnabled
}
if connSettings.AskBeforeWshInstall != nil {
askBeforeInstall = *connSettings.AskBeforeWshInstall
if connSettings.ConnAskBeforeWshInstall != nil {
askBeforeInstall = *connSettings.ConnAskBeforeWshInstall
}
}
if enableWsh {
@ -661,6 +693,9 @@ func GetConnectionsList() ([]string, error) {
hasConnected = append(hasConnected, stat.Connection)
}
}
fromInternal := GetConnectionsFromInternalConfig()
fromConfig, err := GetConnectionsFromConfig()
if err != nil {
return nil, err
@ -670,7 +705,7 @@ func GetConnectionsList() ([]string, error) {
alreadyUsed := make(map[string]struct{})
var connList []string
for _, subList := range [][]string{currentlyRunning, hasConnected, fromConfig} {
for _, subList := range [][]string{currentlyRunning, hasConnected, fromInternal, fromConfig} {
for _, pattern := range subList {
if _, used := alreadyUsed[pattern]; !used {
connList = append(connList, pattern)
@ -682,6 +717,15 @@ func GetConnectionsList() ([]string, error) {
return connList, nil
}
func GetConnectionsFromInternalConfig() []string {
var internalNames []string
config := wconfig.ReadFullConfig()
for internalName := range config.Connections {
internalNames = append(internalNames, internalName)
}
return internalNames
}
func GetConnectionsFromConfig() ([]string, error) {
home := wavebase.GetHomeDir()
localConfig := filepath.Join(home, ".ssh", "config")

View File

@ -27,6 +27,7 @@ import (
"github.com/wavetermdev/waveterm/pkg/userinput"
"github.com/wavetermdev/waveterm/pkg/util/shellutil"
"github.com/wavetermdev/waveterm/pkg/wavebase"
"github.com/wavetermdev/waveterm/pkg/wconfig"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
@ -649,7 +650,13 @@ func ConnectToClient(connCtx context.Context, opts *SSHOpts, currentClient *ssh.
connFlags.SshHostName = opts.SSHHost
connFlags.SshPort = fmt.Sprintf("%d", opts.SSHPort)
sshKeywords, err := combineSshKeywords(connFlags, sshConfigKeywords)
rawName := opts.String()
savedKeywords, ok := wconfig.ReadFullConfig().Connections[rawName]
if !ok {
savedKeywords = wshrpc.ConnKeywords{}
}
sshKeywords, err := combineSshKeywords(connFlags, sshConfigKeywords, &savedKeywords)
if err != nil {
return nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err}
}
@ -685,7 +692,7 @@ func ConnectToClient(connCtx context.Context, opts *SSHOpts, currentClient *ssh.
return client, debugInfo.JumpNum, nil
}
func combineSshKeywords(userProvidedOpts *wshrpc.ConnKeywords, configKeywords *wshrpc.ConnKeywords) (*wshrpc.ConnKeywords, error) {
func combineSshKeywords(userProvidedOpts *wshrpc.ConnKeywords, configKeywords *wshrpc.ConnKeywords, savedKeywords *wshrpc.ConnKeywords) (*wshrpc.ConnKeywords, error) {
sshKeywords := &wshrpc.ConnKeywords{}
if userProvidedOpts.SshUser != "" {
@ -716,7 +723,13 @@ func combineSshKeywords(userProvidedOpts *wshrpc.ConnKeywords, configKeywords *w
sshKeywords.SshPort = "22"
}
sshKeywords.SshIdentityFile = append(userProvidedOpts.SshIdentityFile, configKeywords.SshIdentityFile...)
// use internal config ones
if savedKeywords != nil {
sshKeywords.SshIdentityFile = append(sshKeywords.SshIdentityFile, savedKeywords.SshIdentityFile...)
}
sshKeywords.SshIdentityFile = append(sshKeywords.SshIdentityFile, userProvidedOpts.SshIdentityFile...)
sshKeywords.SshIdentityFile = append(sshKeywords.SshIdentityFile, configKeywords.SshIdentityFile...)
// these are not officially supported in the waveterm frontend but can be configured
// in ssh config files

View File

@ -1,3 +0,0 @@
{
"askbeforewshinstall": true
}

View File

@ -452,8 +452,16 @@ type CommandRemoteWriteFileData struct {
}
type ConnKeywords struct {
WshEnabled *bool `json:"wshenabled,omitempty"`
AskBeforeWshInstall *bool `json:"askbeforewshinstall,omitempty"`
ConnWshEnabled *bool `json:"conn:wshenabled,omitempty"`
ConnAskBeforeWshInstall *bool `json:"conn:askbeforewshinstall,omitempty"`
DisplayHidden *bool `json:"display:hidden,omitempty"`
DisplayOrder float32 `json:"display:order,omitempty"`
TermClear bool `json:"term:*,omitempty"`
TermFontSize float64 `json:"term:fontsize,omitempty"`
TermFontFamily string `json:"term:fontfamily,omitempty"`
TermTheme string `json:"term:theme,omitempty"`
SshUser string `json:"ssh:user,omitempty"`
SshHostName string `json:"ssh:hostname,omitempty"`