diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 0a576aac..7aca7b30 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -12,7 +12,7 @@ "fileLocation": "absolute", "background": { "activeOnStart": true, - "beginsPattern": "^.* page reload .*", + "beginsPattern": "^(?:.* page reload |\\[TypeScript\\]).*", "endsPattern": "^.*\\[TypeScript\\].*" }, "pattern": [ diff --git a/package.json b/package.json index aad58450..349e6bd1 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,26 @@ "default": "v0.19.0", "title": "Kubectl-gadget repository release tag", "description": "Release tag for the stable kubectl-gadget tool." + }, + "azure.customkubectl.commands": { + "type": "array", + "title": "Custom Kubectl commands", + "items": { + "type": "object", + "title": "A kubectl command", + "properties": { + "name": { + "type": "string", + "description": "Name for the command" + }, + "command": { + "type": "string", + "description": "The command (minus 'kubectl' prefix)" + } + } + }, + "default": [], + "description": "All the custom kubectl commands" } } }, @@ -184,30 +204,6 @@ "command": "aks.createClusterNavToAzurePortal", "title": "Create Cluster From Azure Portal" }, - { - "command": "aks.aksKubectlGetPodsCommands", - "title": "Get All Pods" - }, - { - "command": "aks.aksKubectlGetClusterInfoCommands", - "title": "Get Cluster Info" - }, - { - "command": "aks.aksKubectlGetAPIResourcesCommands", - "title": "Get API Resources" - }, - { - "command": "aks.aksKubectlGetNodeCommands", - "title": "Get Nodes" - }, - { - "command": "aks.aksKubectlDescribeServicesCommands", - "title": "Describe Services" - }, - { - "command": "aks.aksKubectlGetEventsCommands", - "title": "Get All Events" - }, { "command": "aks.aksDeleteCluster", "title": "Delete Cluster" @@ -216,29 +212,17 @@ "command": "aks.aksRotateClusterCert", "title": "Rotate Cluster Certificate" }, - { - "command": "aks.aksKubectlK8sHealthzAPIEndpointCommands", - "title": "Healthz check" - }, - { - "command": "aks.aksKubectlK8sLivezAPIEndpointCommands", - "title": "Livez check" - }, - { - "command": "aks.aksKubectlK8sReadyzAPIEndpointCommands", - "title": "Readyz check" - }, { "command": "aks.aksInspektorGadgetShow", "title": "Show Inspektor Gadget" }, { - "command": "aks.createCluster", - "title": "Create Standard Cluster" + "command": "aks.aksRunKubectlCommands", + "title": "Run Kubectl Commands" }, { - "command": "aks.aksCustomKubectlCommand", - "title": "Run Custom Kubectl Command" + "command": "aks.createCluster", + "title": "Create Standard Cluster" } ], "menus": { @@ -287,19 +271,14 @@ "submenu": "aks.createClusterSubMenu", "when": "view == kubernetes.cloudExplorer && viewItem =~ /aks\\.subscription/i" }, - { - "submenu": "aks.runKubectlCmdSubMenu", - "when": "view == kubernetes.cloudExplorer && viewItem =~ /aks\\.cluster/i", - "group": "9@1" - }, { "submenu": "aks.managedClusterOperationSubMenu", "when": "view == kubernetes.cloudExplorer && viewItem =~ /aks\\.cluster/i", "group": "9@3" }, { - "submenu": "aks.K8sAPIEndpointHealthCheck", - "when": "view == kubernetes.cloudExplorer && viewItem =~ /aks\\.cluster/i", + "command": "aks.aksRunKubectlCommands", + "when": "view == kubernetes.cloudExplorer && viewItem =~ /aks\\.cluster/i || view == extension.vsKubernetesExplorer && viewItem =~ /vsKubernetes\\.\\w*cluster$/i", "group": "9@3" }, { @@ -362,36 +341,6 @@ "group": "navigation" } ], - "aks.runKubectlCmdSubMenu": [ - { - "command": "aks.aksKubectlGetPodsCommands", - "group": "navigation" - }, - { - "command": "aks.aksKubectlGetClusterInfoCommands", - "group": "navigation" - }, - { - "command": "aks.aksKubectlGetAPIResourcesCommands", - "group": "navigation" - }, - { - "command": "aks.aksKubectlGetNodeCommands", - "group": "navigation" - }, - { - "command": "aks.aksKubectlDescribeServicesCommands", - "group": "navigation" - }, - { - "command": "aks.aksKubectlGetEventsCommands", - "group": "navigation" - }, - { - "command": "aks.aksCustomKubectlCommand", - "group": "navigation" - } - ], "aks.managedClusterOperationSubMenu": [ { "command": "aks.aksDeleteCluster", @@ -401,20 +350,6 @@ "command": "aks.aksRotateClusterCert", "group": "navigation" } - ], - "aks.K8sAPIEndpointHealthCheck": [ - { - "command": "aks.aksKubectlK8sHealthzAPIEndpointCommands", - "group": "navigation" - }, - { - "command": "aks.aksKubectlK8sLivezAPIEndpointCommands", - "group": "navigation" - }, - { - "command": "aks.aksKubectlK8sReadyzAPIEndpointCommands", - "group": "navigation" - } ] }, "submenus": [ @@ -426,18 +361,10 @@ "id": "aks.ghWorkflowSubMenu", "label": "Create GitHub Workflow" }, - { - "id": "aks.runKubectlCmdSubMenu", - "label": "Run Kubectl Commands" - }, { "id": "aks.managedClusterOperationSubMenu", "label": "Managed Cluster Operations" }, - { - "id": "aks.K8sAPIEndpointHealthCheck", - "label": "Kubernetes API Health Endpoints" - }, { "id": "aks.createClusterSubMenu", "label": "Create Cluster" diff --git a/resources/webviews/aksKubectlCommand/akskubectlcommand.html b/resources/webviews/aksKubectlCommand/akskubectlcommand.html deleted file mode 100644 index 70964d91..00000000 --- a/resources/webviews/aksKubectlCommand/akskubectlcommand.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - AKS Kubectl Commands - - - - - -
-

AKS Kubectl Command Run for {{name}}

-
- -
-
-
- -
-
- - - - - - - - - - - - - - - - -
Command Command Details
Command Runkubectl {{name}}
Command Output
{{{breaklines command}}}
-
-
- -
-
-
- - - \ No newline at end of file diff --git a/src/commands/aksKubectlCommands/aksCustomiseKubectlCommand.ts b/src/commands/aksKubectlCommands/aksCustomiseKubectlCommand.ts deleted file mode 100644 index 9ca290f5..00000000 --- a/src/commands/aksKubectlCommands/aksCustomiseKubectlCommand.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { createInputBoxStep, runMultiStepInput } from '../../multistep-helper/multistep-helper'; -import { IActionContext } from "@microsoft/vscode-azext-utils"; -import { Errorable } from '../utils/errorable'; -import { aksKubectlCommands } from './aksKubectlCommands'; -import * as vscode from 'vscode'; - -interface State { - clusterkubectlcommand: string; -} - -/** - * A single-step input for enabling custom kubectl command. - */ -export default async function aksCustomKubectlCommand( - _context: IActionContext, - target: any -): Promise { - const clusterKubectlCommandStep = createInputBoxStep({ - shouldResume: () => Promise.resolve(false), - getValue: () => '', - prompt: 'Please enter the Kubectl command to run against the cluster. \n Please make sure only arguments following kubectl command are provivded rather then typing kubectl. \n For example if command is kubectl get pods then only type get pods', - validate: validateAKSKubectlClusterCommand, - storeValue: (state, value) => ({...state, clusterkubectlcommand: value}) - }); - - const initialState: Partial = {}; - - const state = await runMultiStepInput('Run Custom Kubectl Command', initialState, clusterKubectlCommandStep); - if (!state) { - // Cancelled - return; - } - - const answer = await vscode.window.showInformationMessage(`Do you want to run command: kubectl ${state.clusterkubectlcommand}, against your AKS cluster.`, "Yes", "No"); - - if (answer === "Yes") { - aksKubectlCommands(_context, target, state.clusterkubectlcommand); - } - -} - -async function validateAKSKubectlClusterCommand(command: string): Promise> { - if (command.trim().length == 0) { - return { succeeded: false, error: 'Invalid AKS Cluster Kubectl Command.' }; - } - - return { succeeded: true, result: undefined }; -} diff --git a/src/commands/aksKubectlCommands/aksKubectlCommands.ts b/src/commands/aksKubectlCommands/aksKubectlCommands.ts index bd3aacf4..9c01c86d 100644 --- a/src/commands/aksKubectlCommands/aksKubectlCommands.ts +++ b/src/commands/aksKubectlCommands/aksKubectlCommands.ts @@ -1,164 +1,50 @@ import * as vscode from 'vscode'; import * as k8s from 'vscode-kubernetes-tools-api'; import { IActionContext } from "@microsoft/vscode-azext-utils"; -import { getAksClusterTreeItem, getClusterProperties, getKubeconfigYaml } from '../utils/clusters'; -import { getExtensionPath, longRunning } from '../utils/host'; -import { Errorable, failed } from '../utils/errorable'; +import { getKubernetesClusterInfo } from '../utils/clusters'; +import { getExtension } from '../utils/host'; +import { failed } from '../utils/errorable'; import * as tmpfile from '../utils/tempfile'; -import AksClusterTreeItem from '../../tree/aksClusterTreeItem'; -import { createWebView, getRenderedContent, getResourceUri } from '../utils/webviews'; -import { invokeKubectlCommand } from '../utils/kubectl'; +import { KubectlDataProvider, KubectlPanel } from '../../panels/KubectlPanel'; +import { getKubectlCustomCommands } from '../utils/config'; -export async function aksKubectlGetPodsCommands( - _context: IActionContext, - target: any -): Promise { - const command = `get pods --all-namespaces`; - await aksKubectlCommands(_context, target, command); -} - -export async function aksKubectlGetClusterInfoCommands( - _context: IActionContext, - target: any -): Promise { - const command = `cluster-info`; - await aksKubectlCommands(_context, target, command); -} - -export async function aksKubectlGetAPIResourcesCommands( - _context: IActionContext, - target: any -): Promise { - const command = `api-resources`; - await aksKubectlCommands(_context, target, command); -} - -export async function aksKubectlGetNodeCommands( - _context: IActionContext, - target: any -): Promise { - const command = `get node`; - await aksKubectlCommands(_context, target, command); -} - -export async function aksKubectlDescribeServicesCommands( - _context: IActionContext, - target: any -): Promise { - const command = `describe services`; - await aksKubectlCommands(_context, target, command); -} - -export async function aksKubectlGetEventsCommands( - _context: IActionContext, - target: any -): Promise { - const command = `get events --all-namespaces`; - await aksKubectlCommands(_context, target, command); -} - -export async function aksKubectlK8sHealthzAPIEndpointCommands( - _context: IActionContext, - target: any -): Promise { - const command = "get --raw /healthz?verbose"; - await aksKubectlCommands(_context, target, command); -} - -export async function aksKubectlK8sLivezAPIEndpointCommands( - _context: IActionContext, - target: any -): Promise { - const command = "get --raw /livez?verbose"; - await aksKubectlCommands(_context, target, command); -} - -export async function aksKubectlK8sReadyzAPIEndpointCommands( - _context: IActionContext, - target: any -): Promise { - const command = "get --raw /readyz?verbose"; - await aksKubectlCommands(_context, target, command); -} - -export async function aksKubectlCommands( - _context: IActionContext, - target: any, - command: string -): Promise { +export async function aksRunKubectlCommands(_context: IActionContext, target: any) { const kubectl = await k8s.extension.kubectl.v1; const cloudExplorer = await k8s.extension.cloudExplorer.v1; + const clusterExplorer = await k8s.extension.clusterExplorer.v1; if (!kubectl.available) { vscode.window.showWarningMessage(`Kubectl is unavailable.`); - return undefined; - } - - const cluster = getAksClusterTreeItem(target, cloudExplorer); - if (failed(cluster)) { - vscode.window.showErrorMessage(cluster.error); - return; + return; } - const extensionPath = getExtensionPath(); - if (failed(extensionPath)) { - vscode.window.showErrorMessage(extensionPath.error); - return; + if (!cloudExplorer.available) { + vscode.window.showWarningMessage(`Cloud explorer is unavailable.`); + return; } - const properties = await longRunning(`Getting properties for cluster ${cluster.result.name}.`, () => getClusterProperties(cluster.result)); - if (failed(properties)) { - vscode.window.showErrorMessage(properties.error); - return undefined; + if (!clusterExplorer.available) { + vscode.window.showWarningMessage(`Cluster explorer is unavailable.`); + return; } - const kubeconfig = await longRunning(`Retrieving kubeconfig for cluster ${cluster.result.name}.`, () => getKubeconfigYaml(cluster.result, properties.result)); - if (failed(kubeconfig)) { - vscode.window.showErrorMessage(kubeconfig.error); - return undefined; + const clusterInfo = await getKubernetesClusterInfo(target, cloudExplorer, clusterExplorer); + if (failed(clusterInfo)) { + vscode.window.showErrorMessage(clusterInfo.error); + return; } - await loadKubectlCommandRun(cluster.result, extensionPath.result, kubeconfig.result, command, kubectl); -} - -async function loadKubectlCommandRun( - cloudTarget: AksClusterTreeItem, - extensionPath: string, - clusterConfig: string, - command: string, - kubectl: k8s.APIAvailable) { - - const clustername = cloudTarget.name; - - await longRunning(`Loading ${clustername} kubectl command run.`, - async () => { - const kubectlresult = await tmpfile.withOptionalTempFile>(clusterConfig, "YAML", async (kubeConfigFile) => { - return await invokeKubectlCommand(kubectl, kubeConfigFile, command); - }); - - if (failed(kubectlresult)) { - vscode.window.showErrorMessage(kubectlresult.error); + const extension = getExtension(); + if (failed(extension)) { + vscode.window.showErrorMessage(extension.error); return; - } - const webview = createWebView('AKS Kubectl Commands', `AKS Kubectl Command view for: ${clustername}`).webview; - webview.html = getWebviewContent(kubectlresult.result, command, extensionPath, webview); } - ); -} -function getWebviewContent( - clusterdata: k8s.KubectlV1.ShellResult, - commandRun: string, - vscodeExtensionPath: string, - webview: vscode.Webview - ): string { - const styleUri = getResourceUri(webview, vscodeExtensionPath, 'common', 'detector.css'); - const templateUri = getResourceUri(webview, vscodeExtensionPath, 'aksKubectlCommand', 'akskubectlcommand.html'); - const data = { - cssuri: styleUri, - name: commandRun, - command: clusterdata.stdout, - }; + const customCommands = getKubectlCustomCommands(); + + const kubeConfigFile = await tmpfile.createTempFile(clusterInfo.result.kubeconfigYaml, "yaml"); + const dataProvider = new KubectlDataProvider(kubectl, kubeConfigFile.filePath, clusterInfo.result.name, customCommands); + const panel = new KubectlPanel(extension.result.extensionUri); - return getRenderedContent(templateUri, data); + panel.show(dataProvider, kubeConfigFile); } diff --git a/src/commands/utils/config.ts b/src/commands/utils/config.ts index b35b15d8..ab9d67fe 100644 --- a/src/commands/utils/config.ts +++ b/src/commands/utils/config.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import { combine, failed, Errorable } from './errorable'; import { KubeloginConfig, KustomizeConfig } from '../periscope/models/config'; import * as semver from "semver"; +import { CommandCategory, PresetCommand } from '../../webview-contract/webviewDefinitions/kubectl'; export function getKustomizeConfig(): Errorable { const periscopeConfig = vscode.workspace.getConfiguration('aks.periscope'); @@ -82,3 +83,29 @@ function getConfigValue(config: vscode.WorkspaceConfiguration, key: string): Err } return { succeeded: true, result: result }; } + +export function getKubectlCustomCommands(): PresetCommand[] { + const config = vscode.workspace.getConfiguration('azure.customkubectl'); + const value = config.get('commands'); + if (!Array.isArray(value)) { + return []; + } + + return value.filter(isCommand).map(item => ({...item, category: CommandCategory.Custom})); + + function isCommand(value: any): value is PresetCommand { + return (value.constructor.name === 'Object') && (value as PresetCommand).command && (value as PresetCommand).name ? true : false; + } +} + +export async function addKubectlCustomCommand(name: string, command: string) { + const currentCommands = getKubectlCustomCommands().map(cmd => ({name: cmd.name, command: cmd.command})); + const commands = [...currentCommands, {name, command}].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + await vscode.workspace.getConfiguration().update('azure.customkubectl.commands', commands, vscode.ConfigurationTarget.Global, true); +} + +export async function deleteKubectlCustomCommand(name: string) { + const currentCommands = getKubectlCustomCommands().map(cmd => ({name: cmd.name, command: cmd.command})); + const commands = currentCommands.filter(cmd => cmd.name !== name); + await vscode.workspace.getConfiguration().update('azure.customkubectl.commands', commands, vscode.ConfigurationTarget.Global, true); +} \ No newline at end of file diff --git a/src/commands/utils/webviews.ts b/src/commands/utils/webviews.ts index 7aab8ad9..fb54860d 100644 --- a/src/commands/utils/webviews.ts +++ b/src/commands/utils/webviews.ts @@ -9,13 +9,6 @@ import { ClusterStartStopState } from './clusters'; // they are registered one time, rather than each time they are used. // -htmlhandlers.registerHelper('breaklines', (text: any): any => { - if (text) { - text = text.replace(/(\r\n|\n|\r)/gm, '
'); - } - return text; -}); - export function createWebView(viewType: string, title: string): vscode.WebviewPanel { const panel = vscode.window.createWebviewPanel( viewType, diff --git a/src/extension.ts b/src/extension.ts index 187ed85d..d11827e7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -23,14 +23,13 @@ import aksNavToPortal from './commands/aksNavToPortal/aksNavToPortal'; import aksClusterProperties from './commands/aksClusterProperties/aksClusterProperties'; import aksCreateClusterNavToAzurePortal from './commands/aksCreateClusterNavToAzurePortal/aksCreateClusterNavToAzurePortal'; import { registerAzureUtilsExtensionVariables } from '@microsoft/vscode-azext-azureutils'; -import { aksKubectlGetPodsCommands, aksKubectlGetClusterInfoCommands, aksKubectlGetAPIResourcesCommands, aksKubectlGetNodeCommands, aksKubectlDescribeServicesCommands, aksKubectlGetEventsCommands, aksKubectlK8sLivezAPIEndpointCommands, aksKubectlK8sHealthzAPIEndpointCommands, aksKubectlK8sReadyzAPIEndpointCommands } from './commands/aksKubectlCommands/aksKubectlCommands'; +import { aksRunKubectlCommands } from './commands/aksKubectlCommands/aksKubectlCommands'; import { longRunning } from './commands/utils/host'; import { getClusterProperties, getKubeconfigYaml } from './commands/utils/clusters'; import aksDeleteCluster from './commands/aksDeleteCluster/aksDeleteCluster'; import aksRotateClusterCert from './commands/aksRotateClusterCert/aksRotateClusterCert'; import { aksInspektorGadgetShow } from './commands/aksInspektorGadget/aksInspektorGadget'; import aksCreateCluster from './commands/aksCreateCluster/aksCreateCluster'; -import aksCustomKubectlCommand from './commands/aksKubectlCommands/aksCustomiseKubectlCommand'; export async function activate(context: vscode.ExtensionContext) { const cloudExplorer = await k8s.extension.cloudExplorer.v1; @@ -68,21 +67,12 @@ export async function activate(context: vscode.ExtensionContext) { registerCommandWithTelemetry('aks.showInPortal', aksNavToPortal ); registerCommandWithTelemetry('aks.clusterProperties', aksClusterProperties); registerCommandWithTelemetry('aks.createClusterNavToAzurePortal', aksCreateClusterNavToAzurePortal); - registerCommandWithTelemetry('aks.aksKubectlGetPodsCommands', aksKubectlGetPodsCommands); - registerCommandWithTelemetry('aks.aksKubectlGetClusterInfoCommands', aksKubectlGetClusterInfoCommands); - registerCommandWithTelemetry('aks.aksKubectlGetAPIResourcesCommands', aksKubectlGetAPIResourcesCommands); - registerCommandWithTelemetry('aks.aksKubectlGetNodeCommands', aksKubectlGetNodeCommands); - registerCommandWithTelemetry('aks.aksKubectlDescribeServicesCommands', aksKubectlDescribeServicesCommands); - registerCommandWithTelemetry('aks.aksKubectlGetEventsCommands', aksKubectlGetEventsCommands); + registerCommandWithTelemetry('aks.aksRunKubectlCommands', aksRunKubectlCommands); registerCommandWithTelemetry('aks.aksCategoryConnectivity', aksCategoryConnectivity); registerCommandWithTelemetry('aks.aksDeleteCluster', aksDeleteCluster); registerCommandWithTelemetry('aks.aksRotateClusterCert', aksRotateClusterCert); - registerCommandWithTelemetry('aks.aksKubectlK8sHealthzAPIEndpointCommands', aksKubectlK8sHealthzAPIEndpointCommands); - registerCommandWithTelemetry('aks.aksKubectlK8sLivezAPIEndpointCommands', aksKubectlK8sLivezAPIEndpointCommands); - registerCommandWithTelemetry('aks.aksKubectlK8sReadyzAPIEndpointCommands', aksKubectlK8sReadyzAPIEndpointCommands); registerCommandWithTelemetry('aks.aksInspektorGadgetShow', aksInspektorGadgetShow); registerCommandWithTelemetry('aks.createCluster', aksCreateCluster); - registerCommandWithTelemetry('aks.aksCustomKubectlCommand', aksCustomKubectlCommand); await registerAzureServiceNodes(context); diff --git a/src/panels/KubectlPanel.ts b/src/panels/KubectlPanel.ts new file mode 100644 index 00000000..ece5489f --- /dev/null +++ b/src/panels/KubectlPanel.ts @@ -0,0 +1,74 @@ +import { Uri } from "vscode"; +import * as k8s from 'vscode-kubernetes-tools-api'; +import { failed } from "../commands/utils/errorable"; +import { MessageHandler, MessageSink } from "../webview-contract/messaging"; +import { BasePanel, PanelDataProvider } from "./BasePanel"; +import { invokeKubectlCommand } from "../commands/utils/kubectl"; +import { InitialState, PresetCommand, ToVsCodeMsgDef, ToWebViewMsgDef } from "../webview-contract/webviewDefinitions/kubectl"; +import { addKubectlCustomCommand, deleteKubectlCustomCommand } from "../commands/utils/config"; + +export class KubectlPanel extends BasePanel<"kubectl"> { + constructor(extensionUri: Uri) { + super(extensionUri, "kubectl"); + } +} + +export class KubectlDataProvider implements PanelDataProvider<"kubectl"> { + constructor( + readonly kubectl: k8s.APIAvailable, + readonly kubeConfigFilePath: string, + readonly clusterName: string, + readonly customCommands: PresetCommand[] + ) { } + + getTitle(): string { + return `Run Kubectl on ${this.clusterName}`; + } + + getInitialState(): InitialState { + return { + clusterName: this.clusterName, + customCommands: this.customCommands + }; + } + + getMessageHandler(webview: MessageSink): MessageHandler { + return { + runCommandRequest: args => this._handleRunCommandRequest(args.command, webview), + addCustomCommandRequest: args => this._handleAddCustomCommandRequest(args.name, args.command), + deleteCustomCommandRequest: args => this._handleDeleteCustomCommandRequest(args.name) + }; + } + + private async _handleRunCommandRequest(command: string, webview: MessageSink) { + const kubectlresult = await invokeKubectlCommand(this.kubectl, this.kubeConfigFilePath, command); + + if (failed(kubectlresult)) { + // TODO: Run some kind of AI processing over the command and error to generate an explanation. + const explanation = undefined; + webview.postMessage({ + command: "runCommandResponse", parameters: { + errorMessage: kubectlresult.error, + explanation + } + }); + + return; + } + + webview.postMessage({ + command: "runCommandResponse", + parameters: { + output: kubectlresult.result.stdout + } + }); + } + + private async _handleAddCustomCommandRequest(name: string, command: string) { + await addKubectlCustomCommand(name, command); + } + + private async _handleDeleteCustomCommandRequest(name: string) { + await deleteKubectlCustomCommand(name); + } +} diff --git a/src/webview-contract/webviewDefinitions/kubectl.ts b/src/webview-contract/webviewDefinitions/kubectl.ts new file mode 100644 index 00000000..58da1bb2 --- /dev/null +++ b/src/webview-contract/webviewDefinitions/kubectl.ts @@ -0,0 +1,59 @@ +import { WebviewDefinition } from "../webviewTypes"; + +export enum CommandCategory { + Resources, + Health, + Custom +} + +const presetCommandItems: [string, string, CommandCategory][] = [ + ["Get All Pods", "get pods --all-namespaces", CommandCategory.Resources], + ["Get Cluster Info", "cluster-info", CommandCategory.Resources], + ["Get API Resources", "api-resources", CommandCategory.Resources], + ["Get Nodes", "get node", CommandCategory.Resources], + ["Describe Services", "describe services", CommandCategory.Resources], + ["Get All Events", "get events --all-namespaces", CommandCategory.Health], + ["Healthz Check", "get --raw /healthz?verbose", CommandCategory.Health], + ["Livez Check", "get --raw /livez?verbose", CommandCategory.Health], + ["Readyz Check", "get --raw /readyz?verbose", CommandCategory.Health] +]; + +export const presetCommands: PresetCommand[] = presetCommandItems.map(cmd => ({ + name: cmd[0], + command: cmd[1], + category: cmd[2] +})); + +export interface PresetCommand { + name: string, + command: string + category: CommandCategory +} + +export interface InitialState { + clusterName: string, + customCommands: PresetCommand[] +} + +export type ToVsCodeMsgDef = { + runCommandRequest: { + command: string + }, + addCustomCommandRequest: { + name: string, + command: string + }, + deleteCustomCommandRequest: { + name: string + } +}; + +export type ToWebViewMsgDef = { + runCommandResponse: { + output?: string + errorMessage?: string + explanation?: string + } +}; + +export type KubectlDefinition = WebviewDefinition; diff --git a/src/webview-contract/webviewTypes.ts b/src/webview-contract/webviewTypes.ts index 7d098e91..574c3009 100644 --- a/src/webview-contract/webviewTypes.ts +++ b/src/webview-contract/webviewTypes.ts @@ -1,5 +1,6 @@ import { Message, MessageContext, MessageDefinition, MessageHandler, MessageSink } from "./messaging"; import { DetectorDefinition } from "./webviewDefinitions/detector"; +import { KubectlDefinition } from "./webviewDefinitions/kubectl"; import { InspektorGadgetDefinition } from "./webviewDefinitions/inspektorGadget"; import { PeriscopeDefinition } from "./webviewDefinitions/periscope"; import { TestStyleViewerDefinition } from "./webviewDefinitions/testStyleViewer"; @@ -21,7 +22,8 @@ type AllWebviewDefinitions = { style: TestStyleViewerDefinition, periscope: PeriscopeDefinition, detector: DetectorDefinition, - gadget: InspektorGadgetDefinition + gadget: InspektorGadgetDefinition, + kubectl: KubectlDefinition }; type ContentIdLookup = { diff --git a/webview-ui/src/Kubectl/CommandInput.tsx b/webview-ui/src/Kubectl/CommandInput.tsx new file mode 100644 index 00000000..2a5c9b9f --- /dev/null +++ b/webview-ui/src/Kubectl/CommandInput.tsx @@ -0,0 +1,32 @@ +import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"; +import styles from "./Kubectl.module.css"; +import { FormEvent } from "react"; + +type ChangeEvent = Event | FormEvent; + +export interface CommandInputProps { + command: string + matchesExisting: boolean + onCommandUpdate: (command: string) => void + onRunCommand: (command: string) => void + onSaveRequest: () => void +} + +export function CommandInput(props: CommandInputProps) { + function handleCommandChange(e: ChangeEvent) { + const input = e.currentTarget as HTMLInputElement; + props.onCommandUpdate(input.value); + } + + const canRun = props.command.trim().length > 0; + return ( +
+ + +
+ props.onRunCommand(props.command)}>Run + {!props.matchesExisting && Save} +
+
+ ); +} \ No newline at end of file diff --git a/webview-ui/src/Kubectl/CommandList.tsx b/webview-ui/src/Kubectl/CommandList.tsx new file mode 100644 index 00000000..99cd27f1 --- /dev/null +++ b/webview-ui/src/Kubectl/CommandList.tsx @@ -0,0 +1,55 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { CommandCategory, PresetCommand } from "../../../src/webview-contract/webviewDefinitions/kubectl" +import styles from "./Kubectl.module.css"; +import { faTrashCan } from "@fortawesome/free-solid-svg-icons"; +import { MouseEvent as ReactMouseEvent } from "react"; + +export interface CommandListProps { + id?: string + className?: React.HTMLAttributes['className'] + commands: PresetCommand[] + selectedCommand: string | null, + onSelectionChanged: (command: PresetCommand) => void + onCommandDelete: (commandName: string) => void +} + +export function CommandList(props: CommandListProps) { + function handleCommandDelete(e: ReactMouseEvent, commandName: string) { + e.preventDefault(); + e.stopPropagation(); + props.onCommandDelete(commandName); + } + + function renderCommands(commands: PresetCommand[], categoryName: string) { + return ( +
  • +

    {categoryName}

    +
      + {commands.map(command => ( +
    • props.onSelectionChanged(command)}> +
      +

      {command.name}

      +
      {command.command}
      +
      + {command.category === CommandCategory.Custom && ( + handleCommandDelete(e, command.name)} /> + )} +
    • + ))} +
    +
  • + ); + } + + const resourceCommands = props.commands.filter(c => c.category === CommandCategory.Resources); + const healthCommands = props.commands.filter(c => c.category === CommandCategory.Health); + const customCommands = props.commands.filter(c => c.category === CommandCategory.Custom); + return ( +
      + {renderCommands(resourceCommands, "Resources")} + {renderCommands(healthCommands, "Health")} + {customCommands.length > 0 && renderCommands(customCommands, "Custom")} +
    + ); +} + diff --git a/webview-ui/src/Kubectl/CommandOutput.tsx b/webview-ui/src/Kubectl/CommandOutput.tsx new file mode 100644 index 00000000..613faba7 --- /dev/null +++ b/webview-ui/src/Kubectl/CommandOutput.tsx @@ -0,0 +1,24 @@ +import { VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react"; +import styles from "./Kubectl.module.css"; + +export interface CommandOutputProps { + isCommandRunning: boolean + output?: string + errorMessage?: string + explanation?: string +} + +export function CommandOutput(props: CommandOutputProps) { + const hasOutput = props.output !== undefined; + const hasError = props.errorMessage !== undefined; + const hasExplanation = props.explanation !== undefined; + + return ( + <> + {props.isCommandRunning && } + {hasOutput &&
    {props.output}
    } + {hasError &&
    {props.errorMessage}
    } + {hasExplanation &&
    {props.explanation}
    } + + ); +} \ No newline at end of file diff --git a/webview-ui/src/Kubectl/Kubectl.module.css b/webview-ui/src/Kubectl/Kubectl.module.css new file mode 100644 index 00000000..206701c8 --- /dev/null +++ b/webview-ui/src/Kubectl/Kubectl.module.css @@ -0,0 +1,113 @@ +.wrapper { + display: grid; + gap: 0.5rem; + grid-template-areas: + "header header" + "nav content"; + grid-template-columns: 1fr 3fr; +} + +.mainHeading { + grid-area: header; +} + +.commandNav { + grid-area: nav; +} + +.mainContent { + grid-area: content; +} + +ul.commandCategoryList { + list-style-type: none; + padding: 0; + padding-left: 0; + margin: 0; +} + +ul.commandCategoryList > li { + display: flex; + flex-direction: column; + margin-bottom: 0.6rem; +} + +ul.commandCategoryList h3,h4 { + margin: 0; + margin-top: 0.2rem; + margin-bottom: 0.2rem; +} + +ul.commandList { + list-style-type: none; + padding: 0; + padding-left: 1rem; + margin: 0; +} + +ul.commandList > li { + display: grid; + grid-template-columns: auto 2rem; + grid-gap: 0.5rem; + margin-bottom: 0.4rem; + align-items: center; +} + +ul.commandList > li.selected { + background-color: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +ul.commandList > li:hover { + cursor: pointer; + background-color: var(--vscode-list-hoverBackground); + color: var(--vscode-list-hoverForeground); +} + +ul.commandList pre { + margin: 0; + font-size: 0.8rem; +} + +ul.commandList .commandDelete { + cursor: pointer; +} + +ul.commandList .commandDelete:hover { + cursor: pointer; + color: var(--vscode-notificationsWarningIcon-foreground); +} + +.inputContainer { + display: grid; + grid-template-columns: auto 10rem; + grid-gap: 0; + grid-column-gap: 0.5rem; + align-items: center; +} + +.inputContainer .label { + grid-column: 1 / 2; + margin-bottom: 0.2rem; +} + +.inputContainer .control, .commands { + margin-bottom: 0.8rem; + min-width: 20rem; +} + +.inputContainer .control { + grid-column: 1 / 1; +} + +.inputContainer .commands { + grid-column: 2 / 2; +} + +.inputContainer .commands > * { + margin-right: 0.5rem; +} + +pre.error { + color: var(--vscode-testing-iconFailed); +} diff --git a/webview-ui/src/Kubectl/Kubectl.tsx b/webview-ui/src/Kubectl/Kubectl.tsx new file mode 100644 index 00000000..610daa9f --- /dev/null +++ b/webview-ui/src/Kubectl/Kubectl.tsx @@ -0,0 +1,102 @@ +import { VSCodeDivider } from "@vscode/webview-ui-toolkit/react"; +import { CommandCategory, InitialState, PresetCommand, presetCommands } from "../../../src/webview-contract/webviewDefinitions/kubectl"; +import styles from "./Kubectl.module.css"; +import { getWebviewMessageContext } from "../utilities/vscode"; +import { useEffect, useState } from "react"; +import { CommandList } from "./CommandList"; +import { CommandInput } from "./CommandInput"; +import { CommandOutput } from "./CommandOutput"; +import { SaveCommandDialog } from "./SaveCommandDialog"; + +interface KubectlState { + allCommands: PresetCommand[] + selectedCommand: string | null + isCommandRunning: boolean + output?: string + errorMessage?: string + explanation?: string + isSaveDialogShown: boolean +} + +export function Kubectl(props: InitialState) { + const vscode = getWebviewMessageContext<"kubectl">(); + + const [state, setState] = useState({ + allCommands: [...presetCommands, ...props.customCommands], + selectedCommand: null, + isCommandRunning: false, + isSaveDialogShown: false + }); + + useEffect(() => { + vscode.subscribeToMessages({ + runCommandResponse: args => setState({...state, output: args.output, errorMessage: args.errorMessage, explanation: args.explanation, isCommandRunning: false}) + }); + }); + + function handleCommandSelectionChanged(command: PresetCommand) { + setState({...state, selectedCommand: command.command, output: undefined, errorMessage: undefined, explanation: undefined}); + } + + function handleCommandDelete(commandName: string) { + const allCommands = state.allCommands.filter(cmd => cmd.name !== commandName); + setState({...state, allCommands}); + vscode.postMessage({ command: "deleteCustomCommandRequest", parameters: {name: commandName} }); + } + + function handleCommandUpdate(command: string) { + setState({...state, selectedCommand: command}); + } + + function handleRunCommand(command: string) { + setState({...state, isCommandRunning: true, output: undefined, errorMessage: undefined}); + vscode.postMessage({ command: "runCommandRequest", parameters: {command: command.trim()} }); + } + + function handleSaveRequest() { + setState({...state, isSaveDialogShown: true}); + } + + function handleSaveDialogCancel() { + setState({...state, isSaveDialogShown: false}); + } + + function handleSaveDialogAccept(commandName: string) { + if (!state.selectedCommand) { + return; + } + + const newCommand: PresetCommand = { + name: commandName, + command: state.selectedCommand.trim(), + category: CommandCategory.Custom + }; + + const allCommands = [...state.allCommands, newCommand].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + setState({...state, allCommands, isSaveDialogShown: false}); + vscode.postMessage({ command: "addCustomCommandRequest", parameters: newCommand }); + } + + const allCommandNames = state.allCommands.map(cmd => cmd.name); + const commandLookup = Object.fromEntries(state.allCommands.map(cmd => [cmd.command, cmd])); + const matchesExisting = state.selectedCommand != null ? state.selectedCommand.trim() in commandLookup : false; + + return ( +
    +
    +

    Kubectl Command Run for {props.clusterName}

    + +
    + +
    + + + +
    + + +
    + ); +} \ No newline at end of file diff --git a/webview-ui/src/Kubectl/SaveCommandDialog.tsx b/webview-ui/src/Kubectl/SaveCommandDialog.tsx new file mode 100644 index 00000000..bdfbd036 --- /dev/null +++ b/webview-ui/src/Kubectl/SaveCommandDialog.tsx @@ -0,0 +1,60 @@ +import { FormEvent, useState } from "react"; +import { Dialog } from "../components/Dialog"; +import { VSCodeButton, VSCodeDivider, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"; +import styles from "./Kubectl.module.css"; + +type ChangeEvent = Event | FormEvent; + +export interface SaveCommandDialogProps { + isShown: boolean + existingNames: string[] + onCancel: () => void + onAccept: (name: string) => void +} + +export function SaveCommandDialog(props: SaveCommandDialogProps) { + const [name, setName] = useState(""); + + const existingNameExists = Object.fromEntries(props.existingNames.map(name => [name, true])); + + function canSave() { + const nameToSave = name.trim(); + return nameToSave.length > 0 && !existingNameExists[nameToSave]; + } + + function handleNameChange(e: ChangeEvent) { + const input = e.currentTarget as HTMLInputElement; + setName(input.value); + } + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + if (!canSave()) { + return; + } + + props.onAccept(name.trim()); + } + + return ( + props.onCancel()}> +

    Save Command As

    + +
    + + +
    + + +
    + + + +
    + Ok + Cancel +
    + +
    + ); +} \ No newline at end of file diff --git a/webview-ui/src/main.tsx b/webview-ui/src/main.tsx index 5a21c63f..718152fb 100644 --- a/webview-ui/src/main.tsx +++ b/webview-ui/src/main.tsx @@ -7,6 +7,7 @@ import { TestStyleViewer } from "./TestStyleViewer/TestStyleViewer"; import { Periscope } from "./Periscope/Periscope"; import { Detector } from "./Detector/Detector"; import { InspektorGadget } from "./InspektorGadget/InspektorGadget"; +import { Kubectl } from "./Kubectl/Kubectl"; // There are two modes of launching this application: // 1. Via the VS Code extension inside a Webview. @@ -35,7 +36,8 @@ function getVsCodeContent(): JSX.Element { style: () => , periscope: () => , detector: () => , - gadget: () => + gadget: () => , + kubectl: () => }; return rendererLookup[vscodeContentId](); diff --git a/webview-ui/src/manualTest/kubectlTests.tsx b/webview-ui/src/manualTest/kubectlTests.tsx new file mode 100644 index 00000000..e91b3fd7 --- /dev/null +++ b/webview-ui/src/manualTest/kubectlTests.tsx @@ -0,0 +1,53 @@ +import { MessageHandler } from "../../../src/webview-contract/messaging"; +import { CommandCategory, InitialState, PresetCommand, ToVsCodeMsgDef } from "../../../src/webview-contract/webviewDefinitions/kubectl"; +import { Kubectl } from "../Kubectl/Kubectl"; +import { Scenario } from "../utilities/manualTest"; +import { getTestVscodeMessageContext } from "../utilities/vscode"; + +const customCommands: PresetCommand[] = [ + {name: "Test 1", command: "get things", category: CommandCategory.Custom}, + {name: "Test 2", command: "get other things", category: CommandCategory.Custom} +]; + +export function getKubectlScenarios() { + const clusterName = "test-cluster"; + const initialState: InitialState = { + clusterName, + customCommands + } + + const webview = getTestVscodeMessageContext<"kubectl">(); + + function getMessageHandler(succeeding: boolean): MessageHandler { + return { + runCommandRequest: args => handleRunCommandRequest(args.command, succeeding), + addCustomCommandRequest: _ => undefined, + deleteCustomCommandRequest: _ => undefined + } + } + + async function handleRunCommandRequest(command: string, succeeding: boolean) { + await new Promise(resolve => setTimeout(resolve, 2000)); + if (succeeding) { + webview.postMessage({ + command: "runCommandResponse", + parameters: { + output: Array.from({length: 20}, (_, i) => `This is the output of "kubectl ${command}" line ${i + 1}`).join('\n') + } + }); + } else { + webview.postMessage({ + command: "runCommandResponse", + parameters: { + errorMessage: "Something went wrong and this is the error.", + explanation: "And here's a natural language explanation of what went wrong." + } + }); + } + } + + return [ + Scenario.create(`Kubectl (succeeding)`, () => ).withSubscription(webview, getMessageHandler(true)), + Scenario.create(`Kubectl (failing)`, () => ).withSubscription(webview, getMessageHandler(false)) + ]; +} diff --git a/webview-ui/src/manualTest/main.tsx b/webview-ui/src/manualTest/main.tsx index 697ad30f..d94e3f1a 100644 --- a/webview-ui/src/manualTest/main.tsx +++ b/webview-ui/src/manualTest/main.tsx @@ -7,6 +7,7 @@ import { getTestStyleViewerScenarios } from "./testStyleViewerTests"; import { getPeriscopeScenarios } from "./periscopeTests"; import { getDetectorScenarios } from "./detectorTests"; import { getInspektorGadgetScenarios } from "./inspektorGadgetTests"; +import { getKubectlScenarios } from "./kubectlTests"; import { ContentId } from "../../../src/webview-contract/webviewTypes"; import { Scenario } from "../utilities/manualTest"; @@ -26,7 +27,8 @@ const contentTestScenarios: Record = { style: getTestStyleViewerScenarios(), periscope: getPeriscopeScenarios(), detector: getDetectorScenarios(), - gadget: getInspektorGadgetScenarios() + gadget: getInspektorGadgetScenarios(), + kubectl: getKubectlScenarios() }; const testScenarios = Object.values(contentTestScenarios).flatMap(s => s);