From 33e62ab78b5d425d7067bbba28cd26893385a4af Mon Sep 17 00:00:00 2001 From: Razz4780 Date: Fri, 31 Jan 2025 16:54:42 +0100 Subject: [PATCH 1/9] Namespace switch implemented --- src/api.ts | 143 +++++++++----------------- src/debugger.ts | 53 ++-------- src/targetQuickPick.ts | 225 +++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 3 +- 4 files changed, 281 insertions(+), 143 deletions(-) create mode 100644 src/targetQuickPick.ts diff --git a/src/api.ts b/src/api.ts index 727ab603..f42bee09 100644 --- a/src/api.ts +++ b/src/api.ts @@ -6,6 +6,7 @@ import { NotificationBuilder } from './notification'; import { MirrordStatus } from './status'; import { EnvVars, VerifiedConfig } from './config'; import { PathLike } from 'fs'; +import { UserSelection } from './targetQuickPick'; /** * Key to access the feedback counter (see `tickFeedbackCounter`) from the global user config. @@ -27,18 +28,6 @@ const DISCORD_COUNTER = 'mirrord-discord-counter'; */ const DISCORD_COUNTER_PROMPT_AFTER = 10; -const TARGET_TYPE_DISPLAY: Record = { - pod: 'Pod', - deployment: 'Deployment', - rollout: 'Rollout', -}; - -// Option in the target selector that represents no target. -const TARGETLESS_TARGET: TargetQuickPick = { - label: "No Target (\"targetless\")", - type: 'targetless' -}; - /** * Level of the notification, different levels map to different notification boxes. */ @@ -122,76 +111,25 @@ function handleIdeMessage(message: IdeMessage) { } } -type TargetQuickPick = vscode.QuickPickItem & ( - { type: 'targetless' } | - { type: 'target' | 'page', value: string } -); - -export class Targets { - private activePage: string; - - private readonly inner: Record; - readonly length: number; - - constructor(targets: string[], lastTarget?: string) { - this.length = targets.length; - - this.inner = targets.reduce((acc, value) => { - const targetType = value.split('/')[0]; - const target: TargetQuickPick = { - label: value, - type: 'target', - value - }; +export class FoundTarget { + public path: string; + public available: boolean; - if (Array.isArray(acc[targetType])) { - acc[targetType]!.push(target); - } else { - acc[targetType] = [target]; - } - - return acc; - }, {} as Targets['inner']); - - - const types = Object.keys(this.inner); - const lastPage = lastTarget?.split("/")?.[0] ?? ''; - - if (types.includes(lastPage)) { - this.activePage = lastPage; - } else { - this.activePage = types[0] ?? ''; - } - } - - private quickPickSelects(): TargetQuickPick[] { - return Object.keys(this.inner) - .filter((value) => value !== this.activePage) - .map((value) => ({ - label: `Show ${TARGET_TYPE_DISPLAY[value] ?? value}s`, - type: 'page', - value - })); - } - - - quickPickItems(): TargetQuickPick[] { - return [ - ...(this.inner[this.activePage] ?? []), - TARGETLESS_TARGET, - ...this.quickPickSelects() - ]; + constructor(path: string, available: boolean) { + this.path = path; + this.available = available; } +} - switchPage(nextPage: TargetQuickPick) { - if (nextPage.type === 'page') { - this.activePage = nextPage.value; - } - } +export class MirrordLsOutput { + public targets: FoundTarget[] = [] + public current_namespace?: string + public namespaces?: string[] } -/// Key used to store the last selected target in the persistent state. -export const LAST_TARGET_KEY = "mirrord-last-target"; +function isRichMirrordLsOutput(output: any): output is MirrordLsOutput { + return "targets" in output && "current_namespace" in output && "namespaces" in output +} // Display error message with help export function mirrordFailure(error: string) { @@ -239,7 +177,7 @@ export class MirrordExecution { /** * Sets up the args that are going to be passed to the mirrord cli. */ -const makeMirrordArgs = (target: string | null, configFilePath: PathLike | null, userExecutable: PathLike | null): readonly string[] => { +const makeMirrordArgs = (target: string | undefined, configFilePath: PathLike | null, userExecutable: PathLike | null): readonly string[] => { let args = ["ext"]; if (target) { @@ -280,7 +218,10 @@ export class MirrordAPI { "MIRRORD_PROGRESS_MODE": "json", // to have "advanced" progress in IDE // eslint-disable-next-line @typescript-eslint/naming-convention - "MIRRORD_PROGRESS_SUPPORT_IDE": "true" + "MIRRORD_PROGRESS_SUPPORT_IDE": "true", + // to have namespaces in the `mirrord ls` output + // eslint-disable-next-line @typescript-eslint/naming-convention + "MIRRORD_LS_RICH_OUTPUT": "true" }; } @@ -345,35 +286,35 @@ export class MirrordAPI { async getBinaryVersion(): Promise { const stdout = await this.exec(["--version"], {}); // parse mirrord x.y.z - return stdout.split(" ")[1].trim(); + return stdout.split(" ")[1]?.trim(); } /** - * Uses `mirrord ls` to get a list of all targets. - * Targets come sorted, with an exception of the last used target being the first on the list. + * Uses `mirrord ls` to get lists of targets and namespaces. + * Targets come sorted. */ - async listTargets(configPath: string | null | undefined): Promise { + async listTargets(configPath: string | null | undefined, configEnv: EnvVars, namespace?: string): Promise { const args = ['ls']; if (configPath) { args.push('-f', configPath); } - const stdout = await this.exec(args, {}); - - const targets: string[] = JSON.parse(stdout); + if (namespace !== undefined) { + args.push('-n', namespace); + } - let lastTarget: string | undefined = globalContext.workspaceState.get(LAST_TARGET_KEY) - || globalContext.globalState.get(LAST_TARGET_KEY); + const stdout = await this.exec(args, configEnv); - if (lastTarget !== undefined) { - const idx = targets.indexOf(lastTarget); - if (idx !== -1) { - targets.splice(idx, 1); - targets.unshift(lastTarget); - } + const targets = JSON.parse(stdout); + let mirrordLsOutput; + if (isRichMirrordLsOutput(targets)) { + mirrordLsOutput = targets; + } else { + mirrordLsOutput = new MirrordLsOutput(); + mirrordLsOutput.targets = (targets as string[]).map(path => new FoundTarget(path, true)); } - return new Targets(targets, lastTarget); + return mirrordLsOutput; } /** @@ -398,7 +339,7 @@ export class MirrordAPI { * * Has 60 seconds timeout */ - async binaryExecute(target: string | null, configFile: string | null, executable: string | null, configEnv: EnvVars): Promise { + async binaryExecute(target: UserSelection, configFile: string | null, executable: string | null, configEnv: EnvVars): Promise { tickMirrordForTeamsCounter(); tickFeedbackCounter(); tickDiscordCounter(); @@ -414,9 +355,15 @@ export class MirrordAPI { reject("timeout"); }, 120 * 1000); - const args = makeMirrordArgs(target, configFile, executable); + const args = makeMirrordArgs(target.path, configFile, executable); + let env: EnvVars; + if (target.namespace) { + env = { MIRRORD_TARGET_NAMESPACE: target.namespace, ...configEnv }; + } else { + env = configEnv; + } - const child = this.spawnCliWithArgsAndEnv(args, configEnv); + const child = this.spawnCliWithArgsAndEnv(args, env); let stderrData = ""; child.stderr.on("data", (data) => stderrData += data.toString()); diff --git a/src/debugger.ts b/src/debugger.ts index 4c4576f1..ead6e05a 100644 --- a/src/debugger.ts +++ b/src/debugger.ts @@ -1,13 +1,14 @@ import * as vscode from 'vscode'; import { globalContext } from './extension'; import { isTargetSet, MirrordConfigManager } from './config'; -import { LAST_TARGET_KEY, MirrordAPI, mirrordFailure, MirrordExecution } from './api'; +import { MirrordAPI, mirrordFailure, MirrordExecution } from './api'; import { updateTelemetries } from './versionCheck'; import { getMirrordBinary } from './binaryManager'; import { platform } from 'node:os'; import { NotificationBuilder } from './notification'; import { setOperatorUsed } from './mirrordForTeams'; import fs from 'fs'; +import { TargetQuickPick, UserSelection } from './targetQuickPick'; const DYLD_ENV_VAR_NAME = "DYLD_INSERT_LIBRARIES"; @@ -109,60 +110,24 @@ async function main( let mirrordApi = new MirrordAPI(cliPath); config.env ||= {}; - let target = null; + let target: UserSelection = {}; let configPath = await MirrordConfigManager.getInstance().resolveMirrordConfig(folder, config); const verifiedConfig = await mirrordApi.verifyConfig(configPath, config.env); // If target wasn't specified in the config file (or there's no config file), let user choose pod from dropdown if (!configPath || (verifiedConfig && !isTargetSet(verifiedConfig))) { - let targets; + const getTargets = async (namespace?: string) => { + return mirrordApi.listTargets(configPath?.path, config.env, namespace); + } + try { - targets = await mirrordApi.listTargets(configPath?.path); + const quickPick = await TargetQuickPick.new(getTargets); + target = await quickPick.showAndGet(); } catch (err) { mirrordFailure(`mirrord failed to list targets: ${err}`); return null; } - if (targets.length === 0) { - new NotificationBuilder() - .withMessage( - "No mirrord target available in the configured namespace. " + - "You can run targetless, or set a different target namespace or kubeconfig in the mirrord configuration file.", - ) - .info(); - } - - let selected = false; - - while (!selected) { - let targetPick = await vscode.window.showQuickPick(targets.quickPickItems(), { - placeHolder: 'Select a target path to mirror' - }); - - if (targetPick) { - if (targetPick.type === 'page') { - targets.switchPage(targetPick); - - continue; - } - - if (targetPick.type !== 'targetless') { - target = targetPick.value; - } - - globalContext.globalState.update(LAST_TARGET_KEY, target); - globalContext.workspaceState.update(LAST_TARGET_KEY, target); - } - - selected = true; - } - - if (!target) { - new NotificationBuilder() - .withMessage("mirrord running targetless") - .withDisableAction("promptTargetless") - .info(); - } } if (config.type === "go") { diff --git a/src/targetQuickPick.ts b/src/targetQuickPick.ts new file mode 100644 index 00000000..31a68f8c --- /dev/null +++ b/src/targetQuickPick.ts @@ -0,0 +1,225 @@ +import * as vscode from 'vscode' +import { MirrordLsOutput } from './api'; +import { globalContext } from './extension'; +import { NotificationBuilder } from './notification'; + +/// Key used to store the last selected target in the persistent state. +const LAST_TARGET_KEY = "mirrord-last-target"; + +type TargetQuickPickPage = { + label: string, + targetType?: string, +}; + +const ALL_QUICK_PICK_PAGES: TargetQuickPickPage[] = [ + { + label: 'Show Deployments', + targetType: 'deployment', + }, + { + label: 'Show Rollouts', + targetType: 'rollouts', + }, + { + label: 'Show Pods', + targetType: 'pods', + }, + { + label: 'Switch Namespace', + }, +]; + +type TargetQuickPickItem = vscode.QuickPickItem & ( + { type: 'target', value?: string } | + { type: 'namespace', value: string } | + { type: 'page', value: TargetQuickPickPage } +); + +const TARGETLESS_ITEM: TargetQuickPickItem = { + type: 'target', + label: 'No Target (\"targetless\")', +} + +export type TargetFetcher = (namespace?: string) => Thenable; + +export type UserSelection = { + path?: string, + namespace?: string, +} + +export class TargetQuickPick { + private lsOutput: MirrordLsOutput; + private activePage?: TargetQuickPickPage; + private readonly lastTarget?: string; + private readonly getTargets: TargetFetcher; + + private constructor(getTargets: TargetFetcher, lsOutput: MirrordLsOutput) { + this.lastTarget = globalContext.workspaceState.get(LAST_TARGET_KEY) || globalContext.globalState.get(LAST_TARGET_KEY); + this.lsOutput = lsOutput; + this.getTargets = getTargets; + this.activePage = this.getDefaultPage() + } + + static async new(getTargets: (namespace?: string) => Thenable): Promise { + const getFilteredTargets = async (namespace?: string) => { + const output = await getTargets(namespace); + output.targets = output.targets.filter(t => { + if (!t.available) { + return false; + } + const targetType = t.path.split('/')[0]; + return ALL_QUICK_PICK_PAGES.find(p => p.targetType === targetType) !== undefined + }); + return output; + } + + const lsOutput = await getFilteredTargets(); + + return new TargetQuickPick(getFilteredTargets, lsOutput); + } + + getDefaultPage(): TargetQuickPickPage | undefined { + let page: TargetQuickPickPage | undefined; + + const lastTargetType = this.lastTarget?.split('/')[0] + if (lastTargetType !== undefined) { + page = ALL_QUICK_PICK_PAGES.find(p => p.targetType === lastTargetType); + } + + if (page === undefined) { + page = this + .lsOutput + .targets + .map(t => { + const targetType = t.path.split('/')[0] ?? ''; + return ALL_QUICK_PICK_PAGES.find(p => p.targetType === targetType) + }) + .find(p => p !== undefined); + } + + return page; + } + + prepareQuickPick(): [string, TargetQuickPickItem[]] { + let items: TargetQuickPickItem[]; + let placeholder: string; + + if (this.activePage === undefined) { + items = [TARGETLESS_ITEM]; + + if (this.lsOutput.namespaces !== undefined) { + const switchNamespacePage = ALL_QUICK_PICK_PAGES.find(p => p.targetType === undefined)! + items.push({ + type: 'page', + value: switchNamespacePage, + label: switchNamespacePage.label, + }); + } + + placeholder = "No available targets" + } else if (this.activePage.targetType === undefined) { + items = this + .lsOutput + .namespaces + ?.filter(ns => ns !== this.lsOutput.current_namespace) + .map(ns => { + return { + type: 'namespace', + value: ns, + label: ns, + }; + }) ?? []; + + ALL_QUICK_PICK_PAGES + .filter(p => { + p.targetType !== undefined + && this.lsOutput.targets.find(t => t.path.startsWith(`${p.targetType}/`)) !== undefined + }) + .forEach(p => { + items.push({ + type: 'page', + value: p, + label: p.label, + }); + }); + + placeholder = "Switch to another namespace"; + } else { + items = this + .lsOutput + .targets + .filter(t => t.path.startsWith(`${this.activePage?.targetType}/`)) + .map(t => { + return { + type: 'target', + value: t.path, + label: t.path, + }; + }); + + if (this.lastTarget !== undefined) { + const idx = items.findIndex(i => i.value === this.lastTarget); + if (idx !== -1) { + const removed = items.splice(idx, 1); + items = removed.concat(items); + } + } + + const redirects = ALL_QUICK_PICK_PAGES + .filter(p => { + p.targetType === undefined + || this.lsOutput.targets.find(t => t.path.startsWith(`${p.targetType}/`)) !== undefined + }) + .forEach(p => { + items.push({ + type: 'page', + value: p, + label: p.label, + }); + }); + + placeholder = "Select a target"; + } + + if (this.lsOutput.current_namespace !== undefined) { + placeholder += ` (current namespace: ${this.lsOutput.current_namespace})`; + } + + return [placeholder, items]; + } + + async showAndGet(): Promise { + while (true) { + const [placeHolder, items] = this.prepareQuickPick() + + const newSelection = await vscode.window.showQuickPick(items, { placeHolder }); + + switch (newSelection?.type) { + case 'target': + if (newSelection.value !== undefined) { + globalContext.globalState.update(LAST_TARGET_KEY, newSelection.value); + globalContext.workspaceState.update(LAST_TARGET_KEY, newSelection.value); + } + + return { path: newSelection.value, namespace: this.lsOutput.current_namespace }; + + case 'namespace': + this.lsOutput = await this.getTargets(newSelection.value); + this.activePage = this.getDefaultPage() + break; + + case 'page': + this.activePage = newSelection.value; + break; + + case undefined: + new NotificationBuilder() + .withMessage("mirrord running targetless") + .withDisableAction("promptTargetless") + .info(); + + return { namespace: this.lsOutput.current_namespace }; + } + } + } +} diff --git a/tsconfig.json b/tsconfig.json index bcbee93c..94f88d55 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,11 +9,12 @@ "sourceMap": true, "rootDir": "src", "esModuleInterop": true, // Without this, will report errors in k8s client node modules https://github.com/kubernetes-client/javascript/issues/751#issuecomment-986953203 - "strict": true /* enable all strict type-checking options */ + "strict": true, /* enable all strict type-checking options */ /* Additional Checks */ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ // "noUnusedParameters": true, /* Report errors on unused parameters. */ + "noUncheckedIndexedAccess": true, }, "exclude": [ "node_modules", From 237a53dfb8bf8e75f93093339e0a1ff36d01b1e8 Mon Sep 17 00:00:00 2001 From: Razz4780 Date: Fri, 31 Jan 2025 16:55:16 +0100 Subject: [PATCH 2/9] npm run format --- src/api.ts | 8 ++++---- src/debugger.ts | 2 +- src/targetQuickPick.ts | 28 ++++++++++++++-------------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/api.ts b/src/api.ts index f42bee09..91fa2f64 100644 --- a/src/api.ts +++ b/src/api.ts @@ -122,13 +122,13 @@ export class FoundTarget { } export class MirrordLsOutput { - public targets: FoundTarget[] = [] - public current_namespace?: string - public namespaces?: string[] + public targets: FoundTarget[] = []; + public current_namespace?: string; + public namespaces?: string[]; } function isRichMirrordLsOutput(output: any): output is MirrordLsOutput { - return "targets" in output && "current_namespace" in output && "namespaces" in output + return "targets" in output && "current_namespace" in output && "namespaces" in output; } // Display error message with help diff --git a/src/debugger.ts b/src/debugger.ts index ead6e05a..d7c70276 100644 --- a/src/debugger.ts +++ b/src/debugger.ts @@ -119,7 +119,7 @@ async function main( if (!configPath || (verifiedConfig && !isTargetSet(verifiedConfig))) { const getTargets = async (namespace?: string) => { return mirrordApi.listTargets(configPath?.path, config.env, namespace); - } + }; try { const quickPick = await TargetQuickPick.new(getTargets); diff --git a/src/targetQuickPick.ts b/src/targetQuickPick.ts index 31a68f8c..d8e4425b 100644 --- a/src/targetQuickPick.ts +++ b/src/targetQuickPick.ts @@ -1,4 +1,4 @@ -import * as vscode from 'vscode' +import * as vscode from 'vscode'; import { MirrordLsOutput } from './api'; import { globalContext } from './extension'; import { NotificationBuilder } from './notification'; @@ -38,14 +38,14 @@ type TargetQuickPickItem = vscode.QuickPickItem & ( const TARGETLESS_ITEM: TargetQuickPickItem = { type: 'target', label: 'No Target (\"targetless\")', -} +}; export type TargetFetcher = (namespace?: string) => Thenable; export type UserSelection = { path?: string, namespace?: string, -} +}; export class TargetQuickPick { private lsOutput: MirrordLsOutput; @@ -57,7 +57,7 @@ export class TargetQuickPick { this.lastTarget = globalContext.workspaceState.get(LAST_TARGET_KEY) || globalContext.globalState.get(LAST_TARGET_KEY); this.lsOutput = lsOutput; this.getTargets = getTargets; - this.activePage = this.getDefaultPage() + this.activePage = this.getDefaultPage(); } static async new(getTargets: (namespace?: string) => Thenable): Promise { @@ -68,10 +68,10 @@ export class TargetQuickPick { return false; } const targetType = t.path.split('/')[0]; - return ALL_QUICK_PICK_PAGES.find(p => p.targetType === targetType) !== undefined + return ALL_QUICK_PICK_PAGES.find(p => p.targetType === targetType) !== undefined; }); return output; - } + }; const lsOutput = await getFilteredTargets(); @@ -81,7 +81,7 @@ export class TargetQuickPick { getDefaultPage(): TargetQuickPickPage | undefined { let page: TargetQuickPickPage | undefined; - const lastTargetType = this.lastTarget?.split('/')[0] + const lastTargetType = this.lastTarget?.split('/')[0]; if (lastTargetType !== undefined) { page = ALL_QUICK_PICK_PAGES.find(p => p.targetType === lastTargetType); } @@ -92,7 +92,7 @@ export class TargetQuickPick { .targets .map(t => { const targetType = t.path.split('/')[0] ?? ''; - return ALL_QUICK_PICK_PAGES.find(p => p.targetType === targetType) + return ALL_QUICK_PICK_PAGES.find(p => p.targetType === targetType); }) .find(p => p !== undefined); } @@ -108,7 +108,7 @@ export class TargetQuickPick { items = [TARGETLESS_ITEM]; if (this.lsOutput.namespaces !== undefined) { - const switchNamespacePage = ALL_QUICK_PICK_PAGES.find(p => p.targetType === undefined)! + const switchNamespacePage = ALL_QUICK_PICK_PAGES.find(p => p.targetType === undefined)!; items.push({ type: 'page', value: switchNamespacePage, @@ -116,7 +116,7 @@ export class TargetQuickPick { }); } - placeholder = "No available targets" + placeholder = "No available targets"; } else if (this.activePage.targetType === undefined) { items = this .lsOutput @@ -133,7 +133,7 @@ export class TargetQuickPick { ALL_QUICK_PICK_PAGES .filter(p => { p.targetType !== undefined - && this.lsOutput.targets.find(t => t.path.startsWith(`${p.targetType}/`)) !== undefined + && this.lsOutput.targets.find(t => t.path.startsWith(`${p.targetType}/`)) !== undefined; }) .forEach(p => { items.push({ @@ -168,7 +168,7 @@ export class TargetQuickPick { const redirects = ALL_QUICK_PICK_PAGES .filter(p => { p.targetType === undefined - || this.lsOutput.targets.find(t => t.path.startsWith(`${p.targetType}/`)) !== undefined + || this.lsOutput.targets.find(t => t.path.startsWith(`${p.targetType}/`)) !== undefined; }) .forEach(p => { items.push({ @@ -190,7 +190,7 @@ export class TargetQuickPick { async showAndGet(): Promise { while (true) { - const [placeHolder, items] = this.prepareQuickPick() + const [placeHolder, items] = this.prepareQuickPick(); const newSelection = await vscode.window.showQuickPick(items, { placeHolder }); @@ -205,7 +205,7 @@ export class TargetQuickPick { case 'namespace': this.lsOutput = await this.getTargets(newSelection.value); - this.activePage = this.getDefaultPage() + this.activePage = this.getDefaultPage(); break; case 'page': From 0b4c5b39867316d1a9a11b37d4f84512807c5f19 Mon Sep 17 00:00:00 2001 From: Razz4780 Date: Fri, 31 Jan 2025 19:29:02 +0100 Subject: [PATCH 3/9] Silence linter --- src/api.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/api.ts b/src/api.ts index 91fa2f64..2d192b4d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -123,6 +123,7 @@ export class FoundTarget { export class MirrordLsOutput { public targets: FoundTarget[] = []; + // eslint-disable-next-line @typescript-eslint/naming-convention public current_namespace?: string; public namespaces?: string[]; } @@ -358,6 +359,7 @@ export class MirrordAPI { const args = makeMirrordArgs(target.path, configFile, executable); let env: EnvVars; if (target.namespace) { + // eslint-disable-next-line @typescript-eslint/naming-convention env = { MIRRORD_TARGET_NAMESPACE: target.namespace, ...configEnv }; } else { env = configEnv; From b2b302690fcd3c2b8d1f89ebe4cfb75156e20e02 Mon Sep 17 00:00:00 2001 From: Razz4780 Date: Fri, 31 Jan 2025 20:07:07 +0100 Subject: [PATCH 4/9] Fixed, working --- src/api.ts | 34 ++++++++++++++-------------- src/targetQuickPick.ts | 50 ++++++++++++++++++++++++++---------------- 2 files changed, 47 insertions(+), 37 deletions(-) diff --git a/src/api.ts b/src/api.ts index 2d192b4d..ab2d304c 100644 --- a/src/api.ts +++ b/src/api.ts @@ -111,22 +111,17 @@ function handleIdeMessage(message: IdeMessage) { } } -export class FoundTarget { - public path: string; - public available: boolean; - - constructor(path: string, available: boolean) { - this.path = path; - this.available = available; - } -} +export type FoundTarget = { + path: string; + available: boolean; +}; -export class MirrordLsOutput { - public targets: FoundTarget[] = []; +export type MirrordLsOutput = { + targets: FoundTarget[]; // eslint-disable-next-line @typescript-eslint/naming-convention - public current_namespace?: string; - public namespaces?: string[]; -} + current_namespace?: string; + namespaces?: string[]; +}; function isRichMirrordLsOutput(output: any): output is MirrordLsOutput { return "targets" in output && "current_namespace" in output && "namespaces" in output; @@ -307,12 +302,15 @@ export class MirrordAPI { const stdout = await this.exec(args, configEnv); const targets = JSON.parse(stdout); - let mirrordLsOutput; + let mirrordLsOutput: MirrordLsOutput; if (isRichMirrordLsOutput(targets)) { mirrordLsOutput = targets; } else { - mirrordLsOutput = new MirrordLsOutput(); - mirrordLsOutput.targets = (targets as string[]).map(path => new FoundTarget(path, true)); + mirrordLsOutput = { + targets: (targets as string[]).map(path => { + return {path, available: true }; + }), + }; } return mirrordLsOutput; @@ -356,7 +354,7 @@ export class MirrordAPI { reject("timeout"); }, 120 * 1000); - const args = makeMirrordArgs(target.path, configFile, executable); + const args = makeMirrordArgs(target.path ?? "targetless", configFile, executable); let env: EnvVars; if (target.namespace) { // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/src/targetQuickPick.ts b/src/targetQuickPick.ts index d8e4425b..ad1bd65a 100644 --- a/src/targetQuickPick.ts +++ b/src/targetQuickPick.ts @@ -18,11 +18,11 @@ const ALL_QUICK_PICK_PAGES: TargetQuickPickPage[] = [ }, { label: 'Show Rollouts', - targetType: 'rollouts', + targetType: 'rollout', }, { label: 'Show Pods', - targetType: 'pods', + targetType: 'pod', }, { label: 'Switch Namespace', @@ -57,7 +57,6 @@ export class TargetQuickPick { this.lastTarget = globalContext.workspaceState.get(LAST_TARGET_KEY) || globalContext.globalState.get(LAST_TARGET_KEY); this.lsOutput = lsOutput; this.getTargets = getTargets; - this.activePage = this.getDefaultPage(); } static async new(getTargets: (namespace?: string) => Thenable): Promise { @@ -78,11 +77,15 @@ export class TargetQuickPick { return new TargetQuickPick(getFilteredTargets, lsOutput); } - getDefaultPage(): TargetQuickPickPage | undefined { + private hasTargetOfType(targetType: string): boolean { + return this.lsOutput.targets.find(t => t.path.startsWith(`${targetType}/`)) !== undefined; + } + + private getDefaultPage(): TargetQuickPickPage | undefined { let page: TargetQuickPickPage | undefined; const lastTargetType = this.lastTarget?.split('/')[0]; - if (lastTargetType !== undefined) { + if (lastTargetType !== undefined && this.hasTargetOfType(lastTargetType)) { page = ALL_QUICK_PICK_PAGES.find(p => p.targetType === lastTargetType); } @@ -100,11 +103,17 @@ export class TargetQuickPick { return page; } - prepareQuickPick(): [string, TargetQuickPickItem[]] { + private prepareQuickPick(): [string, TargetQuickPickItem[]] { + if (this.activePage === undefined) { + this.activePage = this.getDefaultPage(); + } + let items: TargetQuickPickItem[]; let placeholder: string; if (this.activePage === undefined) { + placeholder = "No available targets"; + items = [TARGETLESS_ITEM]; if (this.lsOutput.namespaces !== undefined) { @@ -115,9 +124,9 @@ export class TargetQuickPick { label: switchNamespacePage.label, }); } - - placeholder = "No available targets"; } else if (this.activePage.targetType === undefined) { + placeholder = "Switch to another namespace"; + items = this .lsOutput .namespaces @@ -131,10 +140,7 @@ export class TargetQuickPick { }) ?? []; ALL_QUICK_PICK_PAGES - .filter(p => { - p.targetType !== undefined - && this.lsOutput.targets.find(t => t.path.startsWith(`${p.targetType}/`)) !== undefined; - }) + .filter(p => p.targetType !== undefined && this.hasTargetOfType(p.targetType)) .forEach(p => { items.push({ type: 'page', @@ -142,8 +148,6 @@ export class TargetQuickPick { label: p.label, }); }); - - placeholder = "Switch to another namespace"; } else { items = this .lsOutput @@ -165,10 +169,19 @@ export class TargetQuickPick { } } - const redirects = ALL_QUICK_PICK_PAGES + items.push(TARGETLESS_ITEM); + + ALL_QUICK_PICK_PAGES .filter(p => { - p.targetType === undefined - || this.lsOutput.targets.find(t => t.path.startsWith(`${p.targetType}/`)) !== undefined; + if (p.targetType === undefined) { + return this.lsOutput.namespaces !== undefined; + } + + if (p.targetType === this.activePage?.targetType) { + return false; + } + + return this.hasTargetOfType(p.targetType); }) .forEach(p => { items.push({ @@ -191,7 +204,6 @@ export class TargetQuickPick { async showAndGet(): Promise { while (true) { const [placeHolder, items] = this.prepareQuickPick(); - const newSelection = await vscode.window.showQuickPick(items, { placeHolder }); switch (newSelection?.type) { @@ -205,7 +217,7 @@ export class TargetQuickPick { case 'namespace': this.lsOutput = await this.getTargets(newSelection.value); - this.activePage = this.getDefaultPage(); + this.activePage = undefined; break; case 'page': From 2d66a9d03bbd8e826f128423c8959c68491f36eb Mon Sep 17 00:00:00 2001 From: Razz4780 Date: Fri, 31 Jan 2025 20:08:06 +0100 Subject: [PATCH 5/9] Changelog --- changelog.d/+namespace-selection.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/+namespace-selection.added.md diff --git a/changelog.d/+namespace-selection.added.md b/changelog.d/+namespace-selection.added.md new file mode 100644 index 00000000..efbd59fa --- /dev/null +++ b/changelog.d/+namespace-selection.added.md @@ -0,0 +1 @@ +mirrord target quick pick now allows for switching between Kubernetes namespaces. From 39c177e221c498e6413e487281bafebe158115a6 Mon Sep 17 00:00:00 2001 From: Razz4780 Date: Sun, 2 Feb 2025 14:39:38 +0100 Subject: [PATCH 6/9] Docs --- src/api.ts | 35 +++++++++++++++++- src/targetQuickPick.ts | 83 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 114 insertions(+), 4 deletions(-) diff --git a/src/api.ts b/src/api.ts index ab2d304c..58f31751 100644 --- a/src/api.ts +++ b/src/api.ts @@ -111,18 +111,48 @@ function handleIdeMessage(message: IdeMessage) { } } +/** + * A mirrord target found in the cluster. + */ export type FoundTarget = { + /** + * The path of this target, as in the mirrord config. + */ path: string; + /** + * Whether this target is available. + */ available: boolean; }; +/** + * The new format of `mirrord ls`, including target availability and namespaces info. + */ export type MirrordLsOutput = { + /** + * The targets found in the current namespace. + */ targets: FoundTarget[]; + /** + * The namespace where the lookup was done. + * + * If the CLI does not support listing namespaces, this is undefined. + */ // eslint-disable-next-line @typescript-eslint/naming-convention current_namespace?: string; + /** + * All namespaces visible to the user. + * + * If the CLI does not support listing namespaces, this is undefined. + */ namespaces?: string[]; }; +/** + * Checks whether the JSON value is in the @see MirrordLsOutput format. + * + * @param output JSON parsed from `mirrord ls` stdout + */ function isRichMirrordLsOutput(output: any): output is MirrordLsOutput { return "targets" in output && "current_namespace" in output && "namespaces" in output; } @@ -287,7 +317,10 @@ export class MirrordAPI { /** * Uses `mirrord ls` to get lists of targets and namespaces. - * Targets come sorted. + * + * Note that old CLI versions return only targets. + * + * @see MirrordLsOutput */ async listTargets(configPath: string | null | undefined, configEnv: EnvVars, namespace?: string): Promise { const args = ['ls']; diff --git a/src/targetQuickPick.ts b/src/targetQuickPick.ts index ad1bd65a..ebb74772 100644 --- a/src/targetQuickPick.ts +++ b/src/targetQuickPick.ts @@ -6,11 +6,25 @@ import { NotificationBuilder } from './notification'; /// Key used to store the last selected target in the persistent state. const LAST_TARGET_KEY = "mirrord-last-target"; +/** + * A page in the @see TargetQuickPick. + */ type TargetQuickPickPage = { + /** + * Label to display in the widget. + */ label: string, + /** + * Prefix of targets visible on this page, mirrord config format. + * + * undefined for namespace selection page. + */ targetType?: string, }; +/** + * All pages in the @see TargetQuickPick. + */ const ALL_QUICK_PICK_PAGES: TargetQuickPickPage[] = [ { label: 'Show Deployments', @@ -29,28 +43,72 @@ const ALL_QUICK_PICK_PAGES: TargetQuickPickPage[] = [ }, ]; +/** + * An item in the @see TargetQuickPick. + */ type TargetQuickPickItem = vscode.QuickPickItem & ( - { type: 'target', value?: string } | - { type: 'namespace', value: string } | - { type: 'page', value: TargetQuickPickPage } + { type: 'target', value?: string } | // select target + { type: 'namespace', value: string } | // switch to another namespace + { type: 'page', value: TargetQuickPickPage } // switch to another page (e.g select pod -> select deployment) ); +/** + * The item in the @see TargetQuickPick that represents the targetless mode. + */ const TARGETLESS_ITEM: TargetQuickPickItem = { type: 'target', label: 'No Target (\"targetless\")', }; +/** + * A function used by @see TargetQuickPick to invoke `mirrord ls` in the given namespace. + */ export type TargetFetcher = (namespace?: string) => Thenable; +/** + * Describes what the user has selected with the @see TargetQuickPick. + */ export type UserSelection = { + /** + * Selected target. + * + * undefined if targetless. + */ path?: string, + /** + * Selected namespace. + * + * undefined if the CLI does not support listing namespaces. + */ namespace?: string, }; +/** + * A quick pick allowing the user to select the target and, if the CLI supports listing namepaces, switch the namespace. + */ export class TargetQuickPick { + /** + * Output of the last `mirrord ls` invocation. + * + * Should contain only targets that are available and supported by this widget (deployments, rollouts and pods). + */ private lsOutput: MirrordLsOutput; + /** + * The page we are currently displaying. + */ private activePage?: TargetQuickPickPage; + /** + * Last target that was ever selected by the user. + * + * This target, if present in @see lsOutput, is put first on its page. + * Also, determines initial page. + */ private readonly lastTarget?: string; + /** + * Function used to invoke `mirrord ls` and get its output. + * + * Should return only targets that are available and supported by this widget (deployments, rollouts and pods). + */ private readonly getTargets: TargetFetcher; private constructor(getTargets: TargetFetcher, lsOutput: MirrordLsOutput) { @@ -59,6 +117,11 @@ export class TargetQuickPick { this.getTargets = getTargets; } + /** + * Creates a new instance of this quick pick. + * + * This quick pick can be executed using @see showAndGet. + */ static async new(getTargets: (namespace?: string) => Thenable): Promise { const getFilteredTargets = async (namespace?: string) => { const output = await getTargets(namespace); @@ -77,10 +140,16 @@ export class TargetQuickPick { return new TargetQuickPick(getFilteredTargets, lsOutput); } + /** + * Returns whether @see lsOutput has at least one target of this type. + */ private hasTargetOfType(targetType: string): boolean { return this.lsOutput.targets.find(t => t.path.startsWith(`${targetType}/`)) !== undefined; } + /** + * Returns a default page to display. undefined if @see lsOutput contains no targets. + */ private getDefaultPage(): TargetQuickPickPage | undefined { let page: TargetQuickPickPage | undefined; @@ -103,6 +172,9 @@ export class TargetQuickPick { return page; } + /** + * Prepares a placeholder and items for the quick pick. + */ private prepareQuickPick(): [string, TargetQuickPickItem[]] { if (this.activePage === undefined) { this.activePage = this.getDefaultPage(); @@ -201,6 +273,11 @@ export class TargetQuickPick { return [placeholder, items]; } + /** + * Shows the quick pick and returns user selection. + * + * If the user selected nothing, returns targetless. + */ async showAndGet(): Promise { while (true) { const [placeHolder, items] = this.prepareQuickPick(); From 385c36929486ea9846b6e22f1edad0a4f0aca076 Mon Sep 17 00:00:00 2001 From: Razz4780 Date: Mon, 3 Feb 2025 09:53:28 +0100 Subject: [PATCH 7/9] Changed labels --- src/targetQuickPick.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/targetQuickPick.ts b/src/targetQuickPick.ts index ebb74772..47141f72 100644 --- a/src/targetQuickPick.ts +++ b/src/targetQuickPick.ts @@ -39,7 +39,7 @@ const ALL_QUICK_PICK_PAGES: TargetQuickPickPage[] = [ targetType: 'pod', }, { - label: 'Switch Namespace', + label: 'Select Another Namespace', }, ]; @@ -185,6 +185,9 @@ export class TargetQuickPick { if (this.activePage === undefined) { placeholder = "No available targets"; + if (this.lsOutput.current_namespace !== undefined) { + placeholder += ` in ${this.lsOutput.current_namespace}`; + } items = [TARGETLESS_ITEM]; @@ -197,7 +200,10 @@ export class TargetQuickPick { }); } } else if (this.activePage.targetType === undefined) { - placeholder = "Switch to another namespace"; + placeholder = "Select another namespace"; + if (this.lsOutput.current_namespace !== undefined) { + placeholder += ` (current: ${this.lsOutput.current_namespace})`; + } items = this .lsOutput @@ -221,6 +227,11 @@ export class TargetQuickPick { }); }); } else { + placeholder = "Select a target"; + if (this.lsOutput.current_namespace !== undefined) { + placeholder += ` from ${this.lsOutput.current_namespace}`; + } + items = this .lsOutput .targets @@ -262,12 +273,6 @@ export class TargetQuickPick { label: p.label, }); }); - - placeholder = "Select a target"; - } - - if (this.lsOutput.current_namespace !== undefined) { - placeholder += ` (current namespace: ${this.lsOutput.current_namespace})`; } return [placeholder, items]; From 83cd9d385e3c25c891a33589bfb6331569d8cddf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Smolarek?= <34063647+Razz4780@users.noreply.github.com> Date: Mon, 3 Feb 2025 11:49:28 +0100 Subject: [PATCH 8/9] Update src/targetQuickPick.ts Co-authored-by: Gemma <58080601+gememma@users.noreply.github.com> --- src/targetQuickPick.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/targetQuickPick.ts b/src/targetQuickPick.ts index 47141f72..e31c8313 100644 --- a/src/targetQuickPick.ts +++ b/src/targetQuickPick.ts @@ -98,7 +98,7 @@ export class TargetQuickPick { */ private activePage?: TargetQuickPickPage; /** - * Last target that was ever selected by the user. + * Target that was selected most recently by the user. * * This target, if present in @see lsOutput, is put first on its page. * Also, determines initial page. From edea2b38df7be6317ca34a8a1f310edd4487d9a7 Mon Sep 17 00:00:00 2001 From: Razz4780 Date: Mon, 3 Feb 2025 11:59:10 +0100 Subject: [PATCH 9/9] Remove ugly page search --- src/targetQuickPick.ts | 53 +++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/src/targetQuickPick.ts b/src/targetQuickPick.ts index e31c8313..9e81435f 100644 --- a/src/targetQuickPick.ts +++ b/src/targetQuickPick.ts @@ -17,15 +17,22 @@ type TargetQuickPickPage = { /** * Prefix of targets visible on this page, mirrord config format. * - * undefined for namespace selection page. + * undefined **only** for namespace selection page. */ targetType?: string, }; /** - * All pages in the @see TargetQuickPick. + * Namespace selection page in the @see TargetQuickPick. */ -const ALL_QUICK_PICK_PAGES: TargetQuickPickPage[] = [ +const NAMESPACE_SELECTION_PAGE: TargetQuickPickPage = { + label: 'Select Another Namespace', +}; + +/** + * Target selection pages in the @see TargetQuickPick. + */ +const TARGET_SELECTION_PAGES: (TargetQuickPickPage & {targetType: string})[] = [ { label: 'Show Deployments', targetType: 'deployment', @@ -38,9 +45,6 @@ const ALL_QUICK_PICK_PAGES: TargetQuickPickPage[] = [ label: 'Show Pods', targetType: 'pod', }, - { - label: 'Select Another Namespace', - }, ]; /** @@ -130,7 +134,7 @@ export class TargetQuickPick { return false; } const targetType = t.path.split('/')[0]; - return ALL_QUICK_PICK_PAGES.find(p => p.targetType === targetType) !== undefined; + return TARGET_SELECTION_PAGES.find(p => p.targetType === targetType) !== undefined; }); return output; }; @@ -155,7 +159,7 @@ export class TargetQuickPick { const lastTargetType = this.lastTarget?.split('/')[0]; if (lastTargetType !== undefined && this.hasTargetOfType(lastTargetType)) { - page = ALL_QUICK_PICK_PAGES.find(p => p.targetType === lastTargetType); + page = TARGET_SELECTION_PAGES.find(p => p.targetType === lastTargetType); } if (page === undefined) { @@ -164,7 +168,7 @@ export class TargetQuickPick { .targets .map(t => { const targetType = t.path.split('/')[0] ?? ''; - return ALL_QUICK_PICK_PAGES.find(p => p.targetType === targetType); + return TARGET_SELECTION_PAGES.find(p => p.targetType === targetType); }) .find(p => p !== undefined); } @@ -192,11 +196,10 @@ export class TargetQuickPick { items = [TARGETLESS_ITEM]; if (this.lsOutput.namespaces !== undefined) { - const switchNamespacePage = ALL_QUICK_PICK_PAGES.find(p => p.targetType === undefined)!; items.push({ type: 'page', - value: switchNamespacePage, - label: switchNamespacePage.label, + value: NAMESPACE_SELECTION_PAGE, + label: NAMESPACE_SELECTION_PAGE.label, }); } } else if (this.activePage.targetType === undefined) { @@ -217,8 +220,8 @@ export class TargetQuickPick { }; }) ?? []; - ALL_QUICK_PICK_PAGES - .filter(p => p.targetType !== undefined && this.hasTargetOfType(p.targetType)) + TARGET_SELECTION_PAGES + .filter(p => this.hasTargetOfType(p.targetType)) .forEach(p => { items.push({ type: 'page', @@ -254,18 +257,8 @@ export class TargetQuickPick { items.push(TARGETLESS_ITEM); - ALL_QUICK_PICK_PAGES - .filter(p => { - if (p.targetType === undefined) { - return this.lsOutput.namespaces !== undefined; - } - - if (p.targetType === this.activePage?.targetType) { - return false; - } - - return this.hasTargetOfType(p.targetType); - }) + TARGET_SELECTION_PAGES + .filter(p => (p.targetType !== this.activePage?.targetType) && this.hasTargetOfType(p.targetType)) .forEach(p => { items.push({ type: 'page', @@ -273,6 +266,14 @@ export class TargetQuickPick { label: p.label, }); }); + + if (this.lsOutput.namespaces !== undefined) { + items.push({ + type: 'page', + value: NAMESPACE_SELECTION_PAGE, + label: NAMESPACE_SELECTION_PAGE.label, + }); + } } return [placeholder, items];