Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable saving of Studio access token #3235

Merged
merged 22 commits into from
Feb 16, 2023
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ed5dd88
add prototype for very simple setup of a Studio token
mattseddon Feb 7, 2023
efdccee
wire up new webview
mattseddon Feb 10, 2023
555d6c6
wire up new actions
mattseddon Feb 13, 2023
3443e47
validate studio token input
mattseddon Feb 13, 2023
158c556
switch to settings page if studio access token is saved
mattseddon Feb 13, 2023
a09bbb3
Merge branch 'main' into add-connect-to-studio
mattseddon Feb 13, 2023
17fdc2e
validate dvc.studioAccessToken via package json properties
mattseddon Feb 13, 2023
1112443
add context value which hides view from tree when token is added
mattseddon Feb 13, 2023
b1eebd9
hide show webview command when studio is connected
mattseddon Feb 13, 2023
39beedc
switch from setting to using secrets store
mattseddon Feb 13, 2023
3ca4c8b
revert changes in config key
mattseddon Feb 13, 2023
8d56106
fix enum
mattseddon Feb 13, 2023
b603b78
fix story
mattseddon Feb 13, 2023
926ca41
Merge branch 'main' into add-connect-to-studio
mattseddon Feb 13, 2023
41e4087
restructure connect class
mattseddon Feb 13, 2023
6709213
add connect app tests
mattseddon Feb 14, 2023
c277d93
add integration tests for Connect
mattseddon Feb 14, 2023
605763b
disallow direct access to secrets
mattseddon Feb 14, 2023
a78e1ef
create add studio access token command
mattseddon Feb 14, 2023
bc9cf3a
apply review feedback
mattseddon Feb 16, 2023
72dd056
Merge branch 'main' into add-connect-to-studio
mattseddon Feb 16, 2023
02a0fc3
Merge branch 'main' into add-connect-to-studio
mattseddon Feb 16, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"featurize",
"hardlinks",
"Interactors",
"isat",
"isdir",
"isempty",
"isequal",
Expand Down
37 changes: 37 additions & 0 deletions extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@
"category": "DVC",
"icon": "$(star-full)"
},
{
"title": "%command.addStudioAccessToken%",
"command": "dvc.addStudioAccessToken",
"category": "DVC"
},
{
"title": "%command.addTarget%",
"command": "dvc.addTarget",
Expand Down Expand Up @@ -307,6 +312,11 @@
"category": "DVC",
"icon": "$(close-all)"
},
{
"title": "%command.removeStudioAccessToken%",
"command": "dvc.removeStudioAccessToken",
"category": "DVC"
},
{
"title": "%command.removeTarget%",
"command": "dvc.removeTarget",
Expand Down Expand Up @@ -384,6 +394,11 @@
"command": "dvc.showCommands",
"category": "DVC"
},
{
"title": "%command.showConnect%",
"command": "dvc.showConnect",
"category": "DVC"
},
{
"title": "%command.showExperiments%",
"command": "dvc.showExperiments",
Expand Down Expand Up @@ -622,6 +637,10 @@
"command": "dvc.addStarredExperimentsTableSort",
"when": "dvc.commands.available && dvc.project.available"
},
{
"command": "dvc.addStudioAccessToken",
"when": "dvc.commands.available && dvc.project.available"
},
{
"command": "dvc.addTarget",
"when": "false"
Expand Down Expand Up @@ -750,6 +769,10 @@
"command": "dvc.removeExperimentsTableSorts",
"when": "dvc.commands.available && dvc.project.available"
},
{
"command": "dvc.removeStudioAccessToken",
"when": "dvc.commands.available && dvc.project.available && dvc.studio.connected"
},
{
"command": "dvc.removeTarget",
"when": "false"
Expand Down Expand Up @@ -786,6 +809,10 @@
"command": "dvc.stopQueuedExperiments",
"when": "dvc.commands.available && dvc.project.available"
},
{
"command": "dvc.showConnect",
"when": "dvc.commands.available && dvc.project.available && !dvc.studio.connected"
},
{
"command": "dvc.selectForCompare",
"when": "false"
Expand Down Expand Up @@ -1319,6 +1346,11 @@
"name": "Actions",
"when": "true"
},
{
"id": "dvc.views.studio",
"name": "Studio",
"when": "!dvc.studio.connected"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[F] We only show the tree view when there is no Studio token available in the secrets store. It can still be hidden by the user. We also give them the option to remove the token and save another one.

},
{
"id": "dvc.views.experimentsColumnsTree",
"name": "Columns",
Expand Down Expand Up @@ -1355,6 +1387,11 @@
"view": "dvc.views.actions",
"contents": "[$(beaker) Show Experiments](command:dvc.showExperiments)\n[$(graph-scatter) Show Plots](command:dvc.showPlots)\n[$(play) Run Experiment](command:dvc.runExperiment)"
},
{
"view": "dvc.views.studio",
"contents": "[$(plug) Connect](command:dvc.showConnect)",
"when": "!dvc.studio.connected"
},
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[I] Double check on initial view placement.

{
"view": "dvc.views.welcome",
"contents": "New to the extension?\n[Show Walkthrough](command:dvc.getStarted)\n\nThe extension is currently unable to initialize.\n[Show Setup](command:dvc.showSetup)",
Expand Down
3 changes: 3 additions & 0 deletions extension/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"command.addExperimentsTableSort": "Add Or Update Sort On Experiments Table",
"command.addStarredExperimentsTableFilter": "Filter Experiments Table to Starred",
"command.addStarredExperimentsTableSort": "Sort Experiments Table by Starred",
"command.addStudioAccessToken": "Add Studio Access Token",
"command.addTarget": "Add Target",
"command.applyExperiment": "Apply Experiment To Workspace",
"command.branchExperiment": "Create New Branch From Experiment",
Expand Down Expand Up @@ -39,6 +40,7 @@
"command.removeExperimentQueue": "Remove All Queued Experiments",
"command.removeExperimentsTableFilters": "Remove Filter(s) From Experiments Table",
"command.removeExperimentsTableSorts": "Remove Sort(s) From Experiments Table",
"command.removeStudioAccessToken": "Remove Studio Access Token",
"command.removeTarget": "Remove",
"command.renameTarget": "Rename",
"command.runExperiment": "Run Experiment",
Expand All @@ -50,6 +52,7 @@
"command.shareExperimentAsBranch": "Share Experiment as Branch",
"command.shareExperimentAsCommit": "Commit and Share Experiment",
"command.showCommands": "Show Commands",
"command.showConnect": "Connect to Studio",
"command.showExperiments": "Show Experiments",
"command.showExperimentsAndPlots": "Show Experiments and Plots",
"command.showOutput": "Show DVC Output",
Expand Down
6 changes: 5 additions & 1 deletion extension/src/commands/external.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,5 +95,9 @@ export enum RegisteredCommands {
TRACKED_EXPLORER_SELECT_FOR_COMPARE = 'dvc.selectForCompare',

SETUP_SHOW = 'dvc.showSetup',
SELECT_FOCUSED_PROJECTS = 'dvc.selectFocusedProjects'
SELECT_FOCUSED_PROJECTS = 'dvc.selectFocusedProjects',

CONNECT_SHOW = 'dvc.showConnect',
ADD_STUDIO_ACCESS_TOKEN = 'dvc.addStudioAccessToken',
REMOVE_STUDIO_ACCESS_TOKEN = 'dvc.removeStudioAccessToken'
}
124 changes: 124 additions & 0 deletions extension/src/connect/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { commands, ExtensionContext, SecretStorage } from 'vscode'
import { validateTokenInput } from './inputBox'
import { STUDIO_ACCESS_TOKEN_KEY, isStudioAccessToken } from './token'
import { STUDIO_URL } from './webview/contract'
import { Resource } from '../resourceLocator'
import { ViewKey } from '../webview/constants'
import { MessageFromWebview, MessageFromWebviewType } from '../webview/contract'
import { BaseRepository } from '../webview/repository'
import { Logger } from '../common/logger'
import { getInput, getValidInput } from '../vscode/inputBox'
import { Title } from '../vscode/title'
import { openUrl } from '../vscode/external'
import { ContextKey, setContextValue } from '../vscode/context'
import { RegisteredCommands } from '../commands/external'
import { showInformation } from '../vscode/modal'

export class Connect extends BaseRepository<undefined> {
public readonly viewKey = ViewKey.CONNECT

private readonly secrets: SecretStorage
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[F] This seems like a better API to use instead of saving the token directly into the User's config.


constructor(context: ExtensionContext, webviewIcon: Resource) {
mattseddon marked this conversation as resolved.
Show resolved Hide resolved
super('', webviewIcon)

this.secrets = context.secrets

this.dispose.track(
this.onDidReceivedWebviewMessage(message =>
this.handleMessageFromWebview(message)
)
)

void this.setContext().then(() => this.deferred.resolve())

this.dispose.track(
context.secrets.onDidChange(e => {
if (e.key !== STUDIO_ACCESS_TOKEN_KEY) {
return
}
return this.setContext()
})
)
}

public sendInitialWebviewData(): void {}

public removeStudioAccessToken() {
return this.removeSecret(STUDIO_ACCESS_TOKEN_KEY)
}

public async saveStudioAccessToken() {
const token = await getValidInput(
Title.ENTER_STUDIO_TOKEN,
validateTokenInput,
{ password: true }
)
if (!token) {
return
}

return this.storeSecret(STUDIO_ACCESS_TOKEN_KEY, token)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add some kind of message after the token is saved? Feels a little weird to me that I saved my token then everything related to Studio just disappears from my screen 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I will add a toast/modal

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what the flow looks like now:

Screen.Recording.2023-02-16.at.4.33.03.pm.mov

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me know what you think. We can update in a follow up.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Makes things more clear on the connection and what you can do now that you're connected!

}

private handleMessageFromWebview(message: MessageFromWebview) {
switch (message.type) {
case MessageFromWebviewType.OPEN_STUDIO:
return this.openStudio()
case MessageFromWebviewType.OPEN_STUDIO_PROFILE:
return this.openStudioProfile()
case MessageFromWebviewType.SAVE_STUDIO_TOKEN:
return commands.executeCommand(
RegisteredCommands.ADD_STUDIO_ACCESS_TOKEN
)
default:
Logger.error(`Unexpected message: ${JSON.stringify(message)}`)
}
}

private openStudio() {
return openUrl(STUDIO_URL)
}

private async openStudioProfile() {
const username = await getInput(Title.ENTER_STUDIO_USERNAME)
if (!username) {
return
}
return openUrl(`${STUDIO_URL}/user/${username}/profile`)
}

private async setContext() {
const storedToken = await this.getSecret(STUDIO_ACCESS_TOKEN_KEY)
if (isStudioAccessToken(storedToken)) {
if (this.deferred.state === 'resolved') {
void showInformation(
'Studio is now connected. Use the "Share to Studio" command from an experiment\'s context menu to share experiments.'
)
}
this.webview?.dispose()
return setContextValue(ContextKey.STUDIO_CONNECTED, true)
}

return setContextValue(ContextKey.STUDIO_CONNECTED, false)
}

private getSecret(key: string) {
const secrets = this.getSecrets()
return secrets.get(key)
}

private storeSecret(key: string, value: string) {
const secrets = this.getSecrets()
return secrets.store(key, value)
}

private removeSecret(key: string) {
const secrets = this.getSecrets()
return secrets.delete(key)
}

private getSecrets() {
return this.secrets
}
}
20 changes: 20 additions & 0 deletions extension/src/connect/inputBox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { validateTokenInput } from './inputBox'

describe('validateTokenInput', () => {
const mockedStudioAccessToken =
'isat_1Z4T0zVHvq9Cu03XEe9Zjvx2vkBihfGPdY7FfmEMAagOXfQx'
it('should return the warning if the input is not valid', () => {
expect(
validateTokenInput(mockedStudioAccessToken.slice(0, -1))
).not.toBeNull()
expect(
validateTokenInput(
mockedStudioAccessToken.slice(1, mockedStudioAccessToken.length)
)
).not.toBeNull()
})

it('should return null if the input is valid', () => {
expect(validateTokenInput(mockedStudioAccessToken)).toBeNull()
})
})
8 changes: 8 additions & 0 deletions extension/src/connect/inputBox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { isStudioAccessToken } from './token'

export const validateTokenInput = (input: string | undefined) => {
if (!isStudioAccessToken(input)) {
return 'please enter a valid Studio access token'
}
return null
}
23 changes: 23 additions & 0 deletions extension/src/connect/register.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Connect } from '.'
import { RegisteredCommands } from '../commands/external'
import { InternalCommands } from '../commands/internal'

export const registerConnectCommands = (
connect: Connect,
internalCommands: InternalCommands
): void => {
internalCommands.registerExternalCommand(
RegisteredCommands.CONNECT_SHOW,
() => connect.showWebview()
)

internalCommands.registerExternalCommand(
RegisteredCommands.ADD_STUDIO_ACCESS_TOKEN,
() => connect.saveStudioAccessToken()
)

internalCommands.registerExternalCommand(
RegisteredCommands.REMOVE_STUDIO_ACCESS_TOKEN,
() => connect.removeStudioAccessToken()
)
}
8 changes: 8 additions & 0 deletions extension/src/connect/token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const STUDIO_ACCESS_TOKEN_KEY = 'dvc.studioAccessToken'

export const isStudioAccessToken = (text?: string): boolean => {
if (!text) {
return false
}
return text.startsWith('isat_') && text.length >= 53
}
1 change: 1 addition & 0 deletions extension/src/connect/webview/contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const STUDIO_URL = 'https://studio.iterative.ai'
10 changes: 9 additions & 1 deletion extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { DvcExecutor } from './cli/dvc/executor'
import { DvcRunner } from './cli/dvc/runner'
import { DvcReader } from './cli/dvc/reader'
import { Config } from './config'
import { Connect } from './connect'
import { registerConnectCommands } from './connect/register'
import { Context } from './context'
import { WorkspaceExperiments } from './experiments/workspace'
import { registerExperimentCommands } from './experiments/commands/register'
Expand Down Expand Up @@ -52,12 +54,12 @@ import { stopProcesses } from './processExecution'
import { Flag } from './cli/dvc/constants'
import { LanguageClient } from './languageClient'
import { collectRunningExperimentPids } from './experiments/processExecution/collect'

export class Extension extends Disposable {
protected readonly internalCommands: InternalCommands

private readonly resourceLocator: ResourceLocator
private readonly repositories: WorkspaceRepositories
private readonly connect: Connect
private readonly experiments: WorkspaceExperiments
private readonly plots: WorkspacePlots
private readonly setup: Setup
Expand Down Expand Up @@ -85,6 +87,10 @@ export class Extension extends Disposable {

const config = this.dispose.track(new Config())

this.connect = this.dispose.track(
new Connect(context, this.resourceLocator.dvcIcon)
)

this.dvcExecutor = this.dispose.track(new DvcExecutor(config))
this.dvcReader = this.dispose.track(new DvcReader(config))
this.dvcRunner = this.dispose.track(new DvcRunner(config))
Expand Down Expand Up @@ -185,6 +191,8 @@ export class Extension extends Disposable {
)
)

registerConnectCommands(this.connect, this.internalCommands)

registerExperimentCommands(
this.experiments,
this.internalCommands,
Expand Down
12 changes: 12 additions & 0 deletions extension/src/telemetry/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ export const EventName = Object.assign(
EXTENSION_EXECUTION_DETAILS_CHANGED: 'extension.executionDetails.changed',
EXTENSION_LOAD: 'extension.load',

VIEWS_CONNECT_CLOSED: 'views.connect.closed',
VIEWS_CONNECT_CREATED: 'views.connect.created',
VIEWS_CONNECT_FOCUS_CHANGED: 'views.connect.focusChanged',

VIEWS_EXPERIMENTS_TABLE_CLOSED: 'views.experimentsTable.closed',
VIEWS_EXPERIMENTS_TABLE_COLUMNS_REORDERED:
'views.experimentsTable.columnsReordered',
Expand Down Expand Up @@ -208,6 +212,10 @@ export interface IEventNamePropertyMapping {
[EventName.EXTENSION_SHOW_COMMANDS]: undefined
[EventName.EXTENSION_SHOW_OUTPUT]: undefined

[EventName.VIEWS_CONNECT_CLOSED]: undefined
[EventName.VIEWS_CONNECT_CREATED]: undefined
[EventName.VIEWS_CONNECT_FOCUS_CHANGED]: undefined

[EventName.VIEWS_EXPERIMENTS_TREE_OPENED]: DvcRootCount
[EventName.VIEWS_EXPERIMENTS_FILTER_BY_TREE_OPENED]: DvcRootCount
[EventName.VIEWS_EXPERIMENTS_METRICS_AND_PARAMS_TREE_OPENED]: DvcRootCount
Expand Down Expand Up @@ -273,4 +281,8 @@ export interface IEventNamePropertyMapping {

[EventName.SETUP_SHOW]: undefined
[EventName.SELECT_FOCUSED_PROJECTS]: undefined

[EventName.CONNECT_SHOW]: undefined
[EventName.ADD_STUDIO_ACCESS_TOKEN]: undefined
[EventName.REMOVE_STUDIO_ACCESS_TOKEN]: undefined
}
Loading