diff --git a/.vscode/test.json.code-snippets b/.vscode/test.json.code-snippets deleted file mode 100644 index e69de29b..00000000 diff --git a/package-lock.json b/package-lock.json index d9660e23..cb4ff156 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ }, "devDependencies": { "@openapitools/openapi-generator-cli": "^2.1.18", + "@popperjs/core": "^2.9.2", "@types/byline": "^4.2.31", "@types/chai": "^4.1.7", "@types/collections": "^5.0.0", @@ -71,6 +72,7 @@ "css-loader": "^3.6.0", "cytoscape": "^3.14.0", "cytoscape-dagre": "^2.2.2", + "cytoscape-popper": "^2.0.0", "decache": "^4.5.1", "eslint": "^6.7.2", "eslint-plugin-header": "^3.0.0", @@ -99,6 +101,7 @@ "webpack-cli": "^4.1.0" }, "engines": { + "npm": ">=7.0.0", "vscode": "^1.50.0" } }, @@ -466,6 +469,12 @@ "node": ">=10.13.0" } }, + "node_modules/@popperjs/core": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.9.2.tgz", + "integrity": "sha512-VZMYa7+fXHdwIq1TDhSXoVmSPEGM/aa+6Aiq3nVVJ9bXr24zScr+NlKFKC3iPljA7ho/GAZr+d2jOf5GIRC30Q==", + "dev": true + }, "node_modules/@redhat-developer/vscode-redhat-telemetry": { "version": "0.0.12", "resolved": "https://registry.npmjs.org/@redhat-developer/vscode-redhat-telemetry/-/vscode-redhat-telemetry-0.0.12.tgz", @@ -2310,6 +2319,15 @@ "dagre": "^0.8.5" } }, + "node_modules/cytoscape-popper": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/cytoscape-popper/-/cytoscape-popper-2.0.0.tgz", + "integrity": "sha512-b7WSOn8qXHWtdIXFNmrgc8qkaOs16tMY0EwtRXlxzvn8X+al6TAFrUwZoYATkYSlotfd/36ZMoeKMEoUck6feA==", + "dev": true, + "dependencies": { + "@popperjs/core": "^2.0.0" + } + }, "node_modules/dagre": { "version": "0.8.5", "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", @@ -6500,6 +6518,11 @@ "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, "engines": { "node": ">=0.10.0" } @@ -8405,6 +8428,12 @@ "resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz", "integrity": "sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==" }, + "@popperjs/core": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.9.2.tgz", + "integrity": "sha512-VZMYa7+fXHdwIq1TDhSXoVmSPEGM/aa+6Aiq3nVVJ9bXr24zScr+NlKFKC3iPljA7ho/GAZr+d2jOf5GIRC30Q==", + "dev": true + }, "@redhat-developer/vscode-redhat-telemetry": { "version": "0.0.12", "resolved": "https://registry.npmjs.org/@redhat-developer/vscode-redhat-telemetry/-/vscode-redhat-telemetry-0.0.12.tgz", @@ -10027,6 +10056,15 @@ "dagre": "^0.8.5" } }, + "cytoscape-popper": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/cytoscape-popper/-/cytoscape-popper-2.0.0.tgz", + "integrity": "sha512-b7WSOn8qXHWtdIXFNmrgc8qkaOs16tMY0EwtRXlxzvn8X+al6TAFrUwZoYATkYSlotfd/36ZMoeKMEoUck6feA==", + "dev": true, + "requires": { + "@popperjs/core": "^2.0.0" + } + }, "dagre": { "version": "0.8.5", "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", diff --git a/package.json b/package.json index ed7a3948..c5f78d7f 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ }, "bugs": "https://github.com/redhat-developer/vscode-tekton/issues", "engines": { - "vscode": "^1.50.0" + "vscode": "^1.50.0", + "npm": ">=7.0.0" }, "categories": [ "Snippets", @@ -930,6 +931,7 @@ }, "devDependencies": { "@openapitools/openapi-generator-cli": "^2.1.18", + "@popperjs/core": "^2.9.2", "@types/byline": "^4.2.31", "@types/chai": "^4.1.7", "@types/collections": "^5.0.0", @@ -960,6 +962,7 @@ "css-loader": "^3.6.0", "cytoscape": "^3.14.0", "cytoscape-dagre": "^2.2.2", + "cytoscape-popper": "^2.0.0", "decache": "^4.5.1", "eslint": "^6.7.2", "eslint-plugin-header": "^3.0.0", diff --git a/src/cli-command.ts b/src/cli-command.ts index a9e81691..4b88f42f 100644 --- a/src/cli-command.ts +++ b/src/cli-command.ts @@ -16,6 +16,7 @@ export function newK8sCommand(...k8sArguments: string[]): CliCommand { return createCliCommand('kubectl', ...k8sArguments); } +// eslint-disable-next-line @typescript-eslint/no-explicit-any function verbose(_target: unknown, key: string, descriptor: any): void { let fnKey: string | undefined; let fn: Function; @@ -258,6 +259,10 @@ export class Command { return newK8sCommand('get', 'taskrun', '-l', `tekton.dev/pipelineRun=${pipelineRunName}`); } + static getTask(name: string, type: 'clustertask' | 'task.tekton'): CliCommand { + return newK8sCommand('get', type, name, '-o', 'json'); + } + @verbose static deleteTask(name: string): CliCommand { return newTknCommand('task', 'delete', name, '-f'); diff --git a/src/humanizer.ts b/src/humanizer.ts new file mode 100644 index 00000000..15a29766 --- /dev/null +++ b/src/humanizer.ts @@ -0,0 +1,27 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ +import * as humanize from 'humanize-duration'; + +export const humanizer = humanize.humanizer(createConfig()); +function createConfig(): humanize.HumanizerOptions { + return { + language: 'shortEn', + languages: { + shortEn: { + y: () => 'y', + mo: () => 'mo', + w: () => 'w', + d: () => 'd', + h: () => 'h', + m: () => 'm', + s: () => 's', + ms: () => 'ms', + } + }, + round: true, + largest: 2, + conjunction: ' ' + }; +} diff --git a/src/pipeline/pipeline-graph.ts b/src/pipeline/pipeline-graph.ts index d2ae1d02..373559d0 100644 --- a/src/pipeline/pipeline-graph.ts +++ b/src/pipeline/pipeline-graph.ts @@ -6,22 +6,33 @@ import * as vscode from 'vscode'; import { tektonYaml, pipelineYaml, pipelineRunYaml, TektonYamlType, DeclaredTask, PipelineRunTask } from '../yaml-support/tkn-yaml'; import { YamlDocument, VirtualDocument } from '../yaml-support/yaml-locator'; -import { humanizer, getPipelineRunTaskState } from '../tkn'; -import { NodeOrEdge, NodeData, EdgeData } from '../webview/pipeline-preview/model'; +import { getPipelineRunTaskState, tkn } from '../tkn'; +import { NodeOrEdge, NodeData, EdgeData, StepData } from '../webview/pipeline-preview/model'; import { PipelineRunData, TaskRuns, TaskRun, PipelineRunConditionCheckStatus } from '../tekton'; import { tektonFSUri, tektonVfsProvider } from '../util/tekton-vfs'; import { ContextType } from '../context-type'; +import { humanizer } from '../humanizer'; export interface GraphProvider { - (document: vscode.TextDocument | VirtualDocument, pipelineRun?: PipelineRunData): Promise; + getGraph(document: vscode.TextDocument | VirtualDocument, pipelineRun?: PipelineRunData): Promise; getElementBySelection?(document: vscode.TextDocument, selection: vscode.Selection): string | undefined; + getTaskSteps(document: vscode.TextDocument | VirtualDocument, task: NodeData): Promise; +} +export const pipelineGraph: GraphProvider = { + getGraph: calculatePipelineGraph, + getElementBySelection, + getTaskSteps: getPipelineTaskSteps } +export const pipelineRunGraph: GraphProvider = { + getGraph: calculatePipelineRunGraph, + getTaskSteps: getPipelineRunTaskSteps +} export async function calculatePipelineGraph(document: vscode.TextDocument): Promise { const doc: YamlDocument = await getPipelineDocument(document, TektonYamlType.Pipeline); if (!doc) { - return []; // TODO: throw error there + return []; } const tasks = pipelineYaml.getPipelineTasks(doc); @@ -29,15 +40,14 @@ export async function calculatePipelineGraph(document: vscode.TextDocument): Pro return convertTasksToNode(tasks); } -calculatePipelineGraph.getElementBySelection = function (document: vscode.TextDocument, selection: vscode.Selection): string | undefined { - +export function getElementBySelection(document: vscode.TextDocument, selection: vscode.Selection): string | undefined { return pipelineYaml.findTask(document, selection.start); } export async function calculatePipelineRunGraph(document: VirtualDocument, pipelineRun?: PipelineRunData): Promise { const doc: YamlDocument = await getPipelineDocument(document, TektonYamlType.PipelineRun); if (!doc) { - return []; // TODO: throw error there + return []; } let tasks: DeclaredTask[]; const refOrSpec = pipelineRunYaml.getTektonPipelineRefOrSpec(doc); @@ -110,7 +120,7 @@ function convertTasksToNode(tasks: PipelineRunTask[], includePositions = true): tasks.forEach((task: DeclaredTask) => tasksMap.set( task.id, task)); for (const task of tasks) { - result.push({ data: { id: task.id, label: getLabel(task), type: task.kind, taskRef: task.taskRef, state: task.state, yamlPosition: includePositions ? task.position : undefined, final: task.final } as NodeData }); + result.push({ data: { id: task.id, label: getLabel(task), type: task.kind, taskRef: task.taskRef, state: task.state, yamlPosition: includePositions ? task.position : undefined, final: task.final, steps: task.steps ?? undefined } as NodeData }); for (const after of task.runAfter ?? []) { if (tasksMap.has(after)) { result.push({ data: { source: after, target: task.id, id: `${after}-${ task.id}`, state: tasksMap.get(after).state } as EdgeData }); @@ -161,6 +171,7 @@ function updatePipelineRunTasks(pipelineRun: PipelineRunData, tasks: DeclaredTas const steps = (taskRun as TaskRun).status?.steps; if (steps) { runTask.stepsCount = steps.length; + runTask.steps = steps; let finishedSteps = 0; for (const step of steps) { const terminated = step.terminated; @@ -201,3 +212,35 @@ function findConditionInTaskRun(name: string, taskRuns: TaskRuns): PipelineRunCo } } } + +async function getPipelineTaskSteps(document: vscode.TextDocument | VirtualDocument, task: NodeData): Promise { + if (task.type === 'Task' || task.type === 'ClusterTask') { + try { + const rawTask = await tkn.getRawTask(task.taskRef, task.type); + return rawTask.spec?.steps.map(it => { return {name: it.name}}); + } catch (err) { + console.error(err); + return undefined; + } + } + if (task.type === 'TaskSpec') { + // task steps should be provided by initial parsing if present + return; + } + + return undefined; +} + +async function getPipelineRunTaskSteps(document: vscode.TextDocument | VirtualDocument, task: NodeData): Promise { + + if (task.type === 'Task' || task.type === 'ClusterTask') { + try { + const rawTask = await tkn.getRawTask(task.taskRef, task.type); + return rawTask.spec?.steps.map(it => { return {name: it.name}}); + } catch (err) { + console.error(err); + return undefined; + } + } + return undefined; +} diff --git a/src/pipeline/pipeline-preview.ts b/src/pipeline/pipeline-preview.ts index aaefd78a..c7ee415b 100644 --- a/src/pipeline/pipeline-preview.ts +++ b/src/pipeline/pipeline-preview.ts @@ -6,7 +6,7 @@ import * as vscode from 'vscode'; import { TektonYamlType, tektonYaml, pipelineRunYaml } from '../yaml-support/tkn-yaml'; import { previewManager, PreviewSettings } from './preview-manager'; import { CommandContext, setCommandContext } from '../commands'; -import { calculatePipelineGraph, calculatePipelineRunGraph, askToSelectPipeline } from './pipeline-graph'; +import { askToSelectPipeline, pipelineGraph, pipelineRunGraph } from './pipeline-graph'; import { tektonFSUri, tektonVfsProvider } from '../util/tekton-vfs'; import { telemetryLog } from '../telemetry'; import { ContextType } from '../context-type'; @@ -20,7 +20,7 @@ export async function showPipelinePreview(commandId?: string): Promise { const resourceColumn = (vscode.window.activeTextEditor && vscode.window.activeTextEditor.viewColumn) || vscode.ViewColumn.One; const pipelines = tektonYaml.getTektonDocuments(document, TektonYamlType.Pipeline) if (pipelines?.length > 0) { - previewManager.showPipelinePreview(document, { resourceColumn, previewColumn: resourceColumn + 1, graphProvider: calculatePipelineGraph }); + previewManager.showPipelinePreview(document, { resourceColumn, previewColumn: resourceColumn + 1, graphProvider: pipelineGraph }); return; } @@ -37,7 +37,7 @@ export async function showPipelinePreview(commandId?: string): Promise { previewManager.showPipelinePreview(document, { resourceColumn, previewColumn: resourceColumn + 1, - graphProvider: calculatePipelineRunGraph, + graphProvider: pipelineRunGraph, pipelineRunName: pipelineRunYaml.getPipelineRunName(pipelineRunDoc), pipelineRunStatus: pipelineRunYaml.getPipelineRunStatus(pipelineRunDoc) } as PreviewSettings); @@ -57,7 +57,7 @@ export async function showPipelineRunPreview(name: string, uid?: string): Promis previewManager.createPipelinePreview(pipelineRunDoc, { resourceColumn: vscode.ViewColumn.Active, previewColumn: vscode.ViewColumn.Active, - graphProvider: calculatePipelineRunGraph, + graphProvider: pipelineRunGraph, pipelineRunName: name, pipelineRunStatus: pipelineRunYaml.getPipelineRunStatus(pipelineRun[0]) } as PreviewSettings); diff --git a/src/pipeline/preview.ts b/src/pipeline/preview.ts index 1fd1df20..47f4d978 100644 --- a/src/pipeline/preview.ts +++ b/src/pipeline/preview.ts @@ -13,6 +13,7 @@ import { kubectl } from '../kubectl'; import { PipelineRunData } from '../tekton'; import { NodeData } from '../webview/pipeline-preview/model'; import { VirtualDocument } from '../yaml-support/yaml-locator'; +import { telemetryLogError } from '../telemetry'; export interface PipelinePreviewInput { readonly document: vscode.TextDocument | VirtualDocument; @@ -73,6 +74,11 @@ export class PipelinePreview extends Disposable { case 'onDidClick': this.onDidClick(e.body); break; + case 'getSteps': + this.handleGetSteps(e.body); + break; + default: + console.error(`Cannot handle message: ${e.type}`); } })); @@ -154,6 +160,17 @@ export class PipelinePreview extends Disposable { } + private async handleGetSteps(node: NodeData): Promise { + try { + const steps = await this.graphProvider.getTaskSteps(this.document, node); + this.postMessage({type: 'showSteps', data: steps}); + } catch (err) { + console.error(err); + telemetryLogError('Pipeline Diagram', err); + } + + } + private isPreviewOf(resource: vscode.Uri): boolean { return this.document.uri.fsPath === resource.fsPath; } @@ -166,7 +183,7 @@ export class PipelinePreview extends Disposable { this.setContent(html); try { - const graph = await this.graphProvider(this.document); + const graph = await this.graphProvider.getGraph(this.document); this.postMessage({ type: 'showData', data: graph }); } catch (err) { console.error(err); @@ -175,7 +192,7 @@ export class PipelinePreview extends Disposable { private async updatePipelineRun(run: PipelineRunData): Promise { try { - const graph = await this.graphProvider(this.document, run); + const graph = await this.graphProvider.getGraph(this.document, run); this.postMessage({ type: 'showData', data: graph }); } catch (err) { console.error(err); @@ -191,7 +208,6 @@ export class PipelinePreview extends Disposable { private setContent(html: string): void { const fileName = path.basename(this.document.uri.fsPath); this.editor.title = `Preview ${fileName}`; - // this.editor.iconPath = this.iconPath; //TODO: implement this.editor.webview.options = getWebviewOptions(); this.editor.webview.html = html; } @@ -199,7 +215,6 @@ export class PipelinePreview extends Disposable { private getHmlContent(): string { const nonce = new Date().getTime() + '' + new Date().getMilliseconds(); const rule = this.editor.webview.cspSource; - // return ` @@ -242,13 +257,6 @@ export class PipelinePreview extends Disposable { return out.join('\n'); } - private getImagesUri(): { [key: string]: string } { - const result: { [key: string]: string } = Object.create(null); - result['task'] = this.editor.webview.asWebviewUri(vscode.Uri.file(path.join(contextGlobalState.extensionPath, 'images', 'T.svg'))).toString(); - result['clustertask'] = this.editor.webview.asWebviewUri(vscode.Uri.file(path.join(contextGlobalState.extensionPath, 'images', 'CT.svg'))).toString(); - return result; - } - } function escapeAttribute(value: string | vscode.Uri): string { diff --git a/src/tekton.d.ts b/src/tekton.d.ts index 6ddca114..cf34d94d 100644 --- a/src/tekton.d.ts +++ b/src/tekton.d.ts @@ -123,10 +123,14 @@ export interface Inputs { export interface TknTaskSpec { inputs?: TknInputs; outputs?: TknOutputs; - steps: Array; + steps: TknTaskStep[]; results?: TaskResult[]; } +export interface TknTaskStep { + name: string; +} + export interface TaskResult { name: string; description: string; @@ -357,8 +361,11 @@ export interface PipelineRunConditions { type: string; } -export interface TaskRunSteps extends ContainerState { +export interface TaskStep { name: string; +} + +export interface TaskRunSteps extends TaskStep, ContainerState { container: string; } diff --git a/src/tkn.ts b/src/tkn.ts index df23ef51..8b1d50cb 100644 --- a/src/tkn.ts +++ b/src/tkn.ts @@ -8,7 +8,6 @@ import { TreeItemCollapsibleState, Terminal, workspace, commands } from 'vscode' import { WindowUtil } from './util/windowUtils'; import * as path from 'path'; import { ToolsConfig } from './tools'; -import humanize = require('humanize-duration'); import { TknPipelineResource, TknTask, PipelineRunData, TaskRunStatus, ConditionCheckStatus, PipelineTaskRunData } from './tekton'; import { kubectl } from './kubectl'; import { pipelineExplorer } from './pipeline/pipelineExplorer'; @@ -26,31 +25,9 @@ import { Command } from './cli-command'; import { getPipelineList } from './util/list-tekton-resource'; import { telemetryLog, telemetryLogError } from './telemetry'; -export const humanizer = humanize.humanizer(createConfig()); let tektonResourceCount = {}; -function createConfig(): humanize.HumanizerOptions { - return { - language: 'shortEn', - languages: { - shortEn: { - y: () => 'y', - mo: () => 'mo', - w: () => 'w', - d: () => 'd', - h: () => 'h', - m: () => 'm', - s: () => 's', - ms: () => 'ms', - } - }, - round: true, - largest: 2, - conjunction: ' ' - }; -} - interface NameId { name: string; uid: string; @@ -614,6 +591,22 @@ export class TknImpl implements Tkn { await this.executeInTerminal(Command.restartPipeline(pipeline.getName())); } + async getRawTask(name: string, type: 'Task' | 'ClusterTask' = 'Task'): Promise { + let data: TknTask; + const result = await this.execute(Command.getTask(name, type === 'Task' ? 'task.tekton' : 'clustertask')); + if (result.error) { + console.log(result + 'Std.err when processing tasks'); + return data; + } + try { + data = JSON.parse(result.stdout); + // eslint-disable-next-line no-empty + } catch (ignore) { + } + + return data; + } + async executeInTerminal(command: CliCommand, cwd: string = process.cwd(), name = 'Tekton'): Promise { let toolLocation = await ToolsConfig.detectOrDownload(); if (toolLocation) { diff --git a/src/tree-view/pipelinerun-node.ts b/src/tree-view/pipelinerun-node.ts index 5c999332..578bfe96 100644 --- a/src/tree-view/pipelinerun-node.ts +++ b/src/tree-view/pipelinerun-node.ts @@ -8,8 +8,9 @@ import { ContextType } from '../context-type'; import { PipelineRunData } from '../tekton'; import format = require('string-format'); import { TektonNode, TektonNodeImpl } from './tekton-node'; -import { compareTimeNewestFirst, humanizer, Tkn } from '../tkn'; +import { compareTimeNewestFirst, Tkn } from '../tkn'; import { TaskRunFromPipeline } from './taskrun-for-pipeline-node'; +import { humanizer } from '../humanizer'; export class PipelineRun extends TektonNodeImpl { diff --git a/src/tree-view/task-run-node.ts b/src/tree-view/task-run-node.ts index 02258112..ee5acc5c 100644 --- a/src/tree-view/task-run-node.ts +++ b/src/tree-view/task-run-node.ts @@ -8,8 +8,9 @@ import { TreeItemCollapsibleState, Uri } from 'vscode'; import { ContextType } from '../context-type'; import { ConditionCheckStatus, PipelineTaskRunData, TaskRunStatus } from '../tekton'; import { TektonNode, TektonNodeImpl } from './tekton-node'; -import { getPipelineRunTaskState, humanizer, Tkn } from '../tkn'; +import { getPipelineRunTaskState, Tkn } from '../tkn'; import { IMAGES, ERROR_PATH, PENDING_PATH } from '../icon-path'; +import { humanizer } from '../humanizer'; export class TaskRun extends TektonNodeImpl { diff --git a/src/util/watchResources.ts b/src/util/watchResources.ts index 3e1b36d5..36809bcb 100644 --- a/src/util/watchResources.ts +++ b/src/util/watchResources.ts @@ -9,9 +9,9 @@ import { kubectl, KubectlCommands } from '../kubectl'; import { pipelineExplorer } from '../pipeline/pipelineExplorer'; import { FileContentChangeNotifier, WatchUtil } from './watch'; import { window, workspace } from 'vscode'; -import { humanizer } from '../tkn'; import { getResourceList } from './list-tekton-resource'; import { telemetryLog } from '../telemetry'; +import { humanizer } from '../humanizer'; export const pipelineTriggerStatus = new Map(); const kubeConfigFolder: string = path.join(Platform.getUserHomePath(), '.kube'); diff --git a/src/webview/pipeline-preview/index.ts b/src/webview/pipeline-preview/index.ts index 409339c0..e7666178 100644 --- a/src/webview/pipeline-preview/index.ts +++ b/src/webview/pipeline-preview/index.ts @@ -4,14 +4,17 @@ *-----------------------------------------------------------------------------------------------*/ import * as cytoscape from 'cytoscape'; -import { NodeOrEdge, CyTheme, NodeData } from './model'; +import { NodeOrEdge, CyTheme, NodeData, StepData } from './model'; import * as dagre from 'cytoscape-dagre'; import { debounce } from 'debounce'; +import * as popper from 'cytoscape-popper'; +import { TaskPopup } from './task-popup'; // eslint-disable-next-line @typescript-eslint/no-explicit-any declare let acquireVsCodeApi: any; const vscode = acquireVsCodeApi(); cytoscape.use(dagre); // register extension +cytoscape.use(popper); let cy: cytoscape.Core; const saveState = debounce(() => { @@ -24,8 +27,10 @@ if (previousState) { restore(previousState); } -let highlightedSourceEdges: cytoscape.Collection; -let highlightedTargetEdges: cytoscape.Collection; +let highlightedSourceEdges: cytoscape.EdgeCollection; +let highlightedTargetEdges: cytoscape.EdgeCollection; +let taskInfoPopup: TaskPopup; +let hoveredId: string; window.addEventListener('message', event => { @@ -39,6 +44,11 @@ window.addEventListener('message', event => { case 'removeHighlight': removeHighlight(); break; + case 'showSteps': + showSteps(event.data.data); + break; + default: + console.error(`Cannot handle: ${event.data.type}!`); } }, false); @@ -69,7 +79,11 @@ function highlightNode(nodeId: string): void { previousHighlightNode = cy.$(`#${nodeId}`); previousHighlightNode.data('editing', 'true'); } - +function showSteps(steps: StepData[] | undefined): void { + if (taskInfoPopup) { + taskInfoPopup.setSteps(steps); + } +} function startUpdatingState(): void { cy.on('render', () => saveState()); cy.on('tap', 'node', function (evt) { @@ -82,6 +96,12 @@ function startUpdatingState(): void { } }); + cy.on('pan zoom resize', () => { + if (taskInfoPopup) { + taskInfoPopup.update(); + } + }); + cy.on('mouseover', 'node', (e) => { const node = e.target; const sourceEdges = node.connectedEdges(`edge[source = "${node.data().id}"]`); @@ -97,6 +117,24 @@ function startUpdatingState(): void { targetEdges.style('line-color', theme.targetEdgesColor); targetEdges.style('target-arrow-color', theme.targetEdgesColor); targetEdges.style('z-index', '100'); + const nodeData = node.data(); + if (taskInfoPopup) { + taskInfoPopup.hide(); + taskInfoPopup = undefined; + } + if (nodeData.steps) { + taskInfoPopup = new TaskPopup(node); + hoveredId = node.data().id; + } else { + if (nodeData.type !== 'Condition' && nodeData.type !== 'When') { + taskInfoPopup = new TaskPopup(node); + hoveredId = node.data().id; + vscode.postMessage({ + type: 'getSteps', + body: nodeData + }); + } + } }); cy.on('mouseout', 'node', () => { @@ -110,6 +148,11 @@ function startUpdatingState(): void { highlightedTargetEdges.removeStyle('z-index'); highlightedTargetEdges.removeStyle('target-arrow-color'); } + if (taskInfoPopup) { + taskInfoPopup.hide(); + taskInfoPopup = undefined; + } + hoveredId = undefined; }); } @@ -120,7 +163,10 @@ function restore(state: object): void { } function render(data: NodeOrEdge[]): void { - + if (taskInfoPopup) { + taskInfoPopup.hide(); + taskInfoPopup = undefined; + } cy = cytoscape({ container: document.getElementById('cy'), // container to render in elements: data, @@ -139,6 +185,11 @@ function render(data: NodeOrEdge[]): void { }); startUpdatingState(); + if (hoveredId) { + const node = cy.$(`#${hoveredId}`); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + taskInfoPopup = new TaskPopup(node as any); + } } function updateStyle(): void { @@ -217,6 +268,12 @@ function getStyle(style: CyTheme): cytoscape.Stylesheet[] { 'shape': 'round-rectangle', }, }, + { + selector: 'node[type = "TaskSpec"]', + style: { + 'shape': 'round-rectangle', + }, + }, { selector: 'node[type = "Condition"]', style: { diff --git a/src/webview/pipeline-preview/model.ts b/src/webview/pipeline-preview/model.ts index d78c734b..3d69c346 100644 --- a/src/webview/pipeline-preview/model.ts +++ b/src/webview/pipeline-preview/model.ts @@ -10,10 +10,63 @@ export interface BaseData { export interface NodeData extends BaseData { label: string; type?: string; - id: string; state?: 'Cancelled'| 'Finished' | 'Started' | 'Failed' | 'Unknown'; yamlPosition?: number; final?: boolean; + steps?: StepData[]; + taskRef?: string; +} + +export interface StepData { + name: string; + running?: StepStateRunning; + terminated?: StepStateTerminated; + waiting?: StepStateWaiting; +} + +export interface StepStateRunning { + /** + * Time at which the container was last (re-)started + */ + startedAt?: string; +} + +export interface StepStateTerminated { + /** + * Exit status from the last termination of the container + */ + 'exitCode': number; + /** + * Time at which the container last terminated + */ + 'finishedAt'?: string; + /** + * Message regarding the last termination of the container + */ + 'message'?: string; + /** + * (brief) reason from the last termination of the container + */ + 'reason'?: string; + /** + * Signal from the last termination of the container + */ + 'signal'?: number; + /** + * Time at which previous execution of the container started + */ + 'startedAt'?: string; +} + +export interface StepStateWaiting { + /** + * Message regarding why the container is not yet running. + */ + 'message'?: string; + /** + * (brief) reason the container is not yet running. + */ + 'reason'?: string; } export interface EdgeData extends BaseData { diff --git a/src/webview/pipeline-preview/popup.css b/src/webview/pipeline-preview/popup.css new file mode 100644 index 00000000..3d3401fc --- /dev/null +++ b/src/webview/pipeline-preview/popup.css @@ -0,0 +1,11 @@ +.popup-container { + border: 1px solid var(--vscode-focusBorder); + background-color: var(--vscode-input-background); + padding: 3px; + border-radius: 4px; +} + +.steps-container { + margin: 0; + padding-left: 20px; +} diff --git a/src/webview/pipeline-preview/task-popup.ts b/src/webview/pipeline-preview/task-popup.ts new file mode 100644 index 00000000..bcd3f481 --- /dev/null +++ b/src/webview/pipeline-preview/task-popup.ts @@ -0,0 +1,101 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ +import {Instance as Popper} from '@popperjs/core'; +import {NodeSingular} from 'cytoscape'; +import { StepData } from './model'; +import { BaseWidget} from '../common/widget'; +import {createDiv} from '../common/dom-util'; +import './popup.css'; +import {humanizer} from '../../humanizer'; + +export class TaskPopup extends BaseWidget { + private popper: Popper; + + constructor(private readonly node: NodeSingular & { popper(opts): Popper} ) { + super(); + this.element = createDiv('popup-container'); + this.popper = this.node.popper({ + content: () => { + const steps: StepData[] = node.data().steps; + this.renderSteps(steps); + + document.body.appendChild(this.element); + + return this.element; + }, + popper: {} // my popper options here + }); + + node.on('position', this.update.bind(this)); + } + + private getStateDescription(step: StepData): [string, string, string] { + if (step.running) { + return ['Started', undefined, ` ${humanizer(Date.now() - Date.parse(step.running.startedAt))}`]; + } + if (step.waiting) { + return ['Waiting', `${step.waiting.reason}`, undefined]; + } + + if (step.terminated) { + let status = 'Finished'; + if (step.terminated.reason === 'Error') { + status = 'Error'; + } else if (step.terminated.reason === 'TaskRunCancelled') { + status = 'Cancelled' + } + return [status, undefined, ` ${humanizer(Date.parse(step.terminated.finishedAt) - Date.parse(step.terminated.startedAt))}`]; + } + + return [undefined, undefined, undefined]; + } + + setSteps(steps: StepData[] | undefined): void { + this.node.data().steps = steps; + this.renderSteps(steps, false); + this.update(); + } + + hide(): void { + this.popper.destroy(); + this.element.remove(); + } + + update(): void { + this.popper.update(); + } + + private renderSteps(steps: StepData[], renderLoader = true): void { + this.element.innerHTML = ''; + if (steps) { + const stepsContainer = document.createElement('ul'); + stepsContainer.classList.add('steps-container'); + for (const step of steps) { + const stepElement = document.createElement('li'); + const description = this.getStateDescription(step); + let content = step.name; + if (description[0]) { + content += ` ${description[0]}`; + } + if (description[1]){ + content += `: ${description[1]}`; + } + if (description[2]){ + content += ` ${description[2]}` + } + stepElement.textContent = content; + stepsContainer.appendChild(stepElement); + } + this.element.appendChild(stepsContainer); + } else { + if (renderLoader) { + this.element.innerText = 'Loading...'; + } else { + this.hide(); + } + } + } +} + diff --git a/src/yaml-support/tkn-yaml.ts b/src/yaml-support/tkn-yaml.ts index a9e3c07c..d7623f88 100644 --- a/src/yaml-support/tkn-yaml.ts +++ b/src/yaml-support/tkn-yaml.ts @@ -7,6 +7,7 @@ import { yamlLocator, YamlMap, YamlSequence, YamlNode, YamlDocument, VirtualDocu import * as _ from 'lodash'; import { TknElementType } from '../model/element-type'; import { PipelineTask, PipelineTaskCondition } from '../model/pipeline/pipeline-model'; +import { TaskRunSteps, TaskStep } from '../tekton'; const TEKTON_API = 'tekton.dev/'; const TRIGGER_API = 'triggers.tekton.dev'; @@ -35,9 +36,10 @@ export interface DeclaredTask { name: string; taskRef: string; runAfter: string[]; - kind: 'Task' | 'ClusterTask' | 'Condition' | 'When' | string; + kind: 'Task' | 'ClusterTask' | 'Condition' | 'When' | 'TaskSpec' | string; position?: number; final?: boolean; + steps?: TaskStep[]; } export type RunState = 'Cancelled' | 'Finished' | 'Started' | 'Failed' | 'Unknown'; @@ -48,6 +50,7 @@ export interface PipelineRunTask extends DeclaredTask { completionTime?: string; stepsCount?: number; finishedSteps?: number; + steps?: TaskRunSteps[] | TaskStep[]; } @@ -320,6 +323,15 @@ export class PipelineYaml { } return undefined; } + + // findTaskByNameInTaskSpec(document: vscode.TextDocument, name: string): DeclaredTask { + // const yamlDocuments = yamlLocator.getYamlDocuments(document); + // for (const doc of yamlDocuments) { + // if (tektonYaml.getTektonYamlType(doc) === TektonYamlType.Pipeline) { + + // } + // } + // } } @@ -451,9 +463,19 @@ function toDeclaredTask(taskNode: YamlMap): DeclaredTask { decTask.kind = 'Task'; } } else { - const taskSpec = findNodeByKey('taskSpec', taskNode); + const taskSpec = findNodeByKey('taskSpec', taskNode); if (taskSpec) { - decTask.kind = 'Task' + decTask.kind = 'TaskSpec'; + const steps = findNodeByKey('steps', taskSpec); + if (steps) { + decTask.steps = []; + for (const step of steps.items) { + const name = findNodeByKey('name', step as YamlMap); + if (name) { + decTask.steps.push({name: name.raw}); + } + } + } } } diff --git a/test/pipeline/pipeline-preview.test.ts b/test/pipeline/pipeline-preview.test.ts index e04f5092..e5baee04 100644 --- a/test/pipeline/pipeline-preview.test.ts +++ b/test/pipeline/pipeline-preview.test.ts @@ -12,7 +12,7 @@ import { tektonYaml } from '../../src/yaml-support/tkn-yaml'; import * as preview from '../../src/pipeline/preview-manager'; import { showPipelinePreview } from '../../src/pipeline/pipeline-preview'; import { TektonYamlType } from '../../src/yaml-support/tkn-yaml'; -import { calculatePipelineGraph } from '../../src/pipeline/pipeline-graph'; +import { calculatePipelineGraph, pipelineGraph } from '../../src/pipeline/pipeline-graph'; const expect = chai.expect; chai.use(sinonChai); @@ -50,7 +50,7 @@ suite('pipeline preview', () => { tknDocuments.returns([{}]); showPipelinePreview(); - expect(previewManager.calledOnceWith(doc, { resourceColumn: vscode.ViewColumn.One, previewColumn: vscode.ViewColumn.One + 1, graphProvider: calculatePipelineGraph })).true; + expect(previewManager).calledOnceWith(doc, { resourceColumn: vscode.ViewColumn.One, previewColumn: vscode.ViewColumn.One + 1, graphProvider: pipelineGraph }); }); }); diff --git a/test/yaml-support/tkn-yaml.test.ts b/test/yaml-support/tkn-yaml.test.ts index 5b45ce7f..b7d5de48 100644 --- a/test/yaml-support/tkn-yaml.test.ts +++ b/test/yaml-support/tkn-yaml.test.ts @@ -260,7 +260,30 @@ suite('Tekton yaml', () => { const tasks = pipelineYaml.getPipelineTasks(docs[0]); expect(tasks).is.not.empty; const task = tasks.find(t => t.name === 'build-skaffold-web'); - expect(task.kind).eq('Task'); + expect(task.kind).eq('TaskSpec'); + }); + + test('should add steps name for taskSpec tasks', ()=> { + const yaml = ` + apiVersion: tekton.dev/v1alpha1 + kind: Pipeline + metadata: + name: pipeline-with-when + spec: + tasks: + - name: build-skaffold-web + taskSpec: + steps: + - name: echo + image: ubuntu + script: exit 1 + ` + const docs = tektonYaml.getTektonDocuments({ getText: () => yaml.toString(), version: 2, uri: vscode.Uri.parse('file:///pipeline/task-spec-pipeline.yaml') } as vscode.TextDocument, TektonYamlType.Pipeline); + const tasks = pipelineYaml.getPipelineTasks(docs[0]); + expect(tasks).is.not.empty; + const task = tasks.find(t => t.name === 'build-skaffold-web'); + expect(task.kind).eq('TaskSpec'); + expect(task.steps).deep.eq([{name: 'echo'}]); }); });