Skip to content

Commit

Permalink
Database access through WebUI (#49979)
Browse files Browse the repository at this point in the history
* feat(web): add database terminal access

* chore(web): make explict type cast

* refactor(web): code review suggestions

* chore(web): fix lint errors

* refactor(web): lint errors

* refactor: code review suggestions

* refactor(web): filter wildcard options from connect dialog

* chore(web): lint

* refactor(web): code review suggestions
  • Loading branch information
gabrielcorado committed Dec 16, 2024
1 parent 4c0a9d7 commit 46062a9
Show file tree
Hide file tree
Showing 22 changed files with 953 additions and 20 deletions.
7 changes: 6 additions & 1 deletion web/packages/teleport/src/Console/Console.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import usePageTitle from './usePageTitle';
import useTabRouting from './useTabRouting';
import useOnExitConfirmation from './useOnExitConfirmation';
import useKeyboardNav from './useKeyboardNav';
import { DocumentDb } from './DocumentDb';

const POLL_INTERVAL = 5000; // every 5 sec

Expand Down Expand Up @@ -77,7 +78,9 @@ export default function Console() {
return consoleCtx.refreshParties();
}

const disableNewTab = storeDocs.getNodeDocuments().length > 0;
const disableNewTab =
storeDocs.getNodeDocuments().length > 0 ||
storeDocs.getDbDocuments().length > 0;
const $docs = documents.map(doc => (
<MemoizedDocument doc={doc} visible={doc.id === activeDocId} key={doc.id} />
));
Expand Down Expand Up @@ -139,6 +142,8 @@ function MemoizedDocument(props: { doc: stores.Document; visible: boolean }) {
return <DocumentNodes doc={doc} visible={visible} />;
case 'kubeExec':
return <DocumentKubeExec doc={doc} visible={visible} />;
case 'db':
return <DocumentDb doc={doc} visible={visible} />;
default:
return <DocumentBlank doc={doc} visible={visible} />;
}
Expand Down
219 changes: 219 additions & 0 deletions web/packages/teleport/src/Console/DocumentDb/ConnectDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
/**
* Teleport
* Copyright (C) 2024 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import React, { useCallback, useEffect, useState } from 'react';
import Dialog, {
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from 'design/Dialog';
import { Box, ButtonPrimary, ButtonSecondary, Flex, Indicator } from 'design';

import Validation from 'shared/components/Validation';
import { Option } from 'shared/components/Select';
import {
FieldSelect,
FieldSelectCreatable,
} from 'shared/components/FieldSelect';

import { Danger } from 'design/Alert';
import { requiredField } from 'shared/components/Validation/rules';
import { useAsync } from 'shared/hooks/useAsync';

import { useTeleport } from 'teleport';
import { Database } from 'teleport/services/databases';
import { DbConnectData } from 'teleport/lib/term/tty';

export function ConnectDialog(props: {
clusterId: string;
serviceName: string;
onClose(): void;
onConnect(data: DbConnectData): void;
}) {
// Fetch database information to pre-fill the connection parameters.
const ctx = useTeleport();
const [attempt, getDatabase] = useAsync(
useCallback(async () => {
const response = await ctx.resourceService.fetchUnifiedResources(
props.clusterId,
{
query: `name == "${props.serviceName}"`,
kinds: ['db'],
sort: { fieldName: 'name', dir: 'ASC' },
limit: 1,
}
);

// TODO(gabrielcorado): Handle scenarios where there is conflict on the name.
if (response.agents.length !== 1 || response.agents[0].kind !== 'db') {
throw new Error('Unable to retrieve database information.');
}

return response.agents[0];
}, [props.clusterId, ctx.resourceService, props.serviceName])
);

useEffect(() => {
void getDatabase();
}, [getDatabase]);

return (
<Dialog
dialogCss={dialogCss}
disableEscapeKeyDown={false}
onClose={props.onClose}
open={true}
>
<DialogHeader mb={4}>
<DialogTitle>Connect To Database</DialogTitle>
</DialogHeader>

{attempt.status === 'error' && <Danger children={attempt.statusText} />}
{(attempt.status === '' || attempt.status === 'processing') && (
<Box textAlign="center" m={10}>
<Indicator />
</Box>
)}
{attempt.status === 'success' && (
<ConnectForm
db={attempt.data}
onConnect={props.onConnect}
onClose={props.onClose}
/>
)}
</Dialog>
);
}

function ConnectForm(props: {
db: Database;
onConnect(data: DbConnectData): void;
onClose(): void;
}) {
const dbUserOpts = props.db.users
?.map(user => ({
value: user,
label: user,
}))
.filter(removeWildcardOption);
const dbNamesOpts = props.db.names
?.map(name => ({
value: name,
label: name,
}))
.filter(removeWildcardOption);
const dbRolesOpts = props.db.roles
?.map(role => ({
value: role,
label: role,
}))
.filter(removeWildcardOption);

const [selectedName, setSelectedName] = useState<Option>(dbNamesOpts?.[0]);
const [selectedUser, setSelectedUser] = useState<Option>(dbUserOpts?.[0]);
const [selectedRoles, setSelectedRoles] =
useState<readonly Option[]>(dbRolesOpts);

const dbConnect = () => {
props.onConnect({
serviceName: props.db.name,
protocol: props.db.protocol,
dbName: selectedName.value,
dbUser: selectedUser.value,
dbRoles: selectedRoles?.map(role => role.value),
});
};

return (
<Validation>
{({ validator }) => (
<form>
<DialogContent flex="0 0 auto">
<FieldSelectCreatable
label="Database name"
menuPosition="fixed"
onChange={option => setSelectedName(option as Option)}
value={selectedName}
options={dbNamesOpts}
formatCreateLabel={userInput =>
`Use "${userInput}" database name`
}
rule={requiredField('Database name is required')}
/>
<FieldSelectCreatable
label="Database user"
menuPosition="fixed"
onChange={option => setSelectedUser(option as Option)}
value={selectedUser}
options={dbUserOpts}
formatCreateLabel={userInput =>
`Use "${userInput}" database user`
}
rule={requiredField('Database user is required')}
/>
{dbRolesOpts?.length > 0 && (
<FieldSelect
label="Database roles"
menuPosition="fixed"
isMulti={true}
onChange={setSelectedRoles}
value={selectedRoles}
options={dbRolesOpts}
rule={requiredField('At least one database role is required')}
/>
)}
</DialogContent>
<DialogFooter>
<Flex alignItems="center" justifyContent="space-between">
<ButtonSecondary
type="button"
width="45%"
size="large"
onClick={props.onClose}
>
Close
</ButtonSecondary>
<ButtonPrimary
type="submit"
width="45%"
size="large"
onClick={e => {
e.preventDefault();
validator.validate() && dbConnect();
}}
>
Connect
</ButtonPrimary>
</Flex>
</DialogFooter>
</form>
)}
</Validation>
);
}

function removeWildcardOption({ value }: Option): boolean {
return value !== '*';
}

const dialogCss = () => `
min-height: 200px;
max-width: 600px;
width: 100%;
`;
Loading

0 comments on commit 46062a9

Please sign in to comment.