From 491190de8613e3c29dc4869e64d6592dca781b76 Mon Sep 17 00:00:00 2001 From: Neil MacDougall Date: Tue, 15 Sep 2020 13:57:30 +0100 Subject: [PATCH] Helm: Improve experience for editing chart values (#469) * Indicate if the Helm chart has a schema * Fixes * Fix unit tests * Update go.sum * Replace foundation DB with sql db and file cache * Tidy ups and final tweaks to get versions working * Tidy up build and remove fdb * Update .gitignore * Remove unused functions * FIx upgrade and add in sources * Improve the edit experience for entering Helm chart values * Fix unit test * Remove fdb helm chart values * Fix default cache folder name * Remove unused commented code * Fix comments * Formatting * Tidy ups * Fix stylesheet issue with var * Update comments * Add missing import following refactor * Fixes for diff and for form <-> editor transitions * Fix issues with async delete and edit * Tidy up chartName * Fix HelmChartID rename to HelmChartReference * Fixes * FIx backend merge issues * Fixes * Get upgrade working * Fix unit tests due to moving of create release component * Additional unit test fixes * One more test fix * Fix for tests * Import MD App Module for unit test fix * Remove commented out code block * Use drop down menu for the values actions * Improve comments and remove console.log * Set volume for helm cache when deployed to k8s * Fix fro kuberenetes deployment * Ensure db statements aer modified for different DBs * Address PR feedback * Fix missing param to error log msg * Add support for retrieving icon for a specific version * Use icon for the chart version. Reduce loading indicators * Bug fix for icon on list view * Fix icons on Helm Hub * Merge fixes * Fix merge issue * Fix merge issue * Fix unit tests * Address PR feedback --- angular.json | 12 +- package.json | 8 +- .../suse-extensions/sass/_all-theme.scss | 3 +- .../create-release.component.scss | 50 -- .../create-release/create-release.module.ts | 19 - .../src/custom/helm/helm.module.ts | 2 - .../src/custom/helm/helm.routing.ts | 3 - .../chart-details-usage.component.ts | 2 +- .../chart-values-editor.component.html | 63 +++ .../chart-values-editor.component.scss | 104 +++++ .../chart-values-editor.component.spec.ts | 41 ++ .../chart-values-editor.component.theme.scss | 65 +++ .../chart-values-editor.component.ts | 427 ++++++++++++++++++ .../chart-values-editor/diffvalues.ts | 50 ++ .../json-schema-generator.ts | 288 ++++++++++++ .../create-release.component.html | 13 +- .../create-release.component.scss | 18 + .../create-release.component.spec.ts | 20 +- .../create-release.component.ts | 93 ++-- .../helm-release-summary-tab.component.ts | 1 + .../upgrade-release.component.html | 25 +- .../upgrade-release.component.ts | 50 +- .../kubernetes/workloads/workloads.module.ts | 14 + .../kubernetes/workloads/workloads.routing.ts | 3 + 24 files changed, 1172 insertions(+), 202 deletions(-) delete mode 100644 src/frontend/packages/suse-extensions/src/custom/helm/create-release/create-release.component.scss delete mode 100644 src/frontend/packages/suse-extensions/src/custom/helm/create-release/create-release.module.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/chart-values-editor.component.html create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/chart-values-editor.component.scss create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/chart-values-editor.component.spec.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/chart-values-editor.component.theme.scss create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/chart-values-editor.component.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/diffvalues.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/json-schema-generator.ts rename src/frontend/packages/suse-extensions/src/custom/{helm => kubernetes/workloads}/create-release/create-release.component.html (70%) create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/create-release/create-release.component.scss rename src/frontend/packages/suse-extensions/src/custom/{helm => kubernetes/workloads}/create-release/create-release.component.spec.ts (61%) rename src/frontend/packages/suse-extensions/src/custom/{helm => kubernetes/workloads}/create-release/create-release.component.ts (73%) diff --git a/angular.json b/angular.json index 001160c86a..79b33bcd31 100644 --- a/angular.json +++ b/angular.json @@ -28,7 +28,17 @@ "input": "custom-src/frontend/assets/custom", "output": "/core/assets/custom" }, - "src/frontend/packages/core/favicon.ico" + "src/frontend/packages/core/favicon.ico", + { + "glob": "**/*", + "input": "node_modules/ngx-monaco-editor/assets/monaco", + "output": "/core/assets/monaco" + }, + { + "glob": "**/*", + "input": "node_modules/@cfstratos/monaco-yaml/lib", + "output": "/core/assets/monaco/vs/language/yaml" + } ], "styles": [ "src/frontend/packages/core/src/styles.scss", diff --git a/package.json b/package.json index 15e022fa11..0806aac4f6 100644 --- a/package.json +++ b/package.json @@ -61,31 +61,33 @@ "@angular/platform-browser-dynamic": "^9.1.6", "@angular/platform-server": "^9.1.6", "@angular/router": "^9.1.6", + "@cfstratos/ajsf-material": "^0.1.6", + "@cfstratos/monaco-yaml": "^2.5.0", "@ngrx/effects": "^9.1.2", "@ngrx/router-store": "^9.1.2", "@ngrx/store": "^9.1.2", "@ngrx/store-devtools": "^9.1.2", "@swimlane/ngx-charts": "^13.0.3", "@swimlane/ngx-graph": "^7.0.1", - "@types/moment-timezone": "^0.5.13", "@types/marked": "^0.7.4", + "@types/moment-timezone": "^0.5.13", "angular2-virtual-scroll": "^0.4.16", "core-js": "^3.6.5", "immer": "^6.0.3", + "intersect": "^1.0.1", "lodash-es": "^4.17.14", "mappy-breakpoints": "^0.2.3", "marked": "^0.8.2", - "intersect": "^1.0.1", "moment": "^2.24.0", "moment-timezone": "^0.5.28", "ngrx-store-localstorage": "9.0.0", "ngx-moment": "^3.5.0", + "ngx-monaco-editor": "^9.0.0", "normalizr": "^3.6.0", "reselect": "^4.0.0", "rxjs": "^6.5.5", "rxjs-spy": "^7.0.2", "rxjs-websockets": "~8.0.1", - "@cfstratos/ajsf-material": "^0.1.5", "ts-md5": "^1.2.7", "tslib": "^1.10.0", "web-animations-js": "^2.3.2", diff --git a/src/frontend/packages/suse-extensions/sass/_all-theme.scss b/src/frontend/packages/suse-extensions/sass/_all-theme.scss index 116316ed35..3d775747a6 100644 --- a/src/frontend/packages/suse-extensions/sass/_all-theme.scss +++ b/src/frontend/packages/suse-extensions/sass/_all-theme.scss @@ -6,6 +6,7 @@ @import '../src/custom/helm/list-types/monocular-chart-card/monocular-chart-card.component.theme'; @import '../src/custom/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.theme'; @import '../src/custom/kubernetes/list-types/kubernetes-nodes/kubernetes-node-link/kubernetes-node-link.component.theme'; +@import '../src/custom/kubernetes/workloads/chart-values-editor/chart-values-editor.component.theme'; @mixin apply-theme-suse-extensions($stratos-theme) { @@ -18,5 +19,5 @@ @include monocular-chart-card($theme, $app-theme); @include helm-release-summary-tab-theme($theme, $app-theme); @include kube-node-link-theme($theme, $app-theme); - + @include app-chart-values-editor-theme($theme, $app-theme); } diff --git a/src/frontend/packages/suse-extensions/src/custom/helm/create-release/create-release.component.scss b/src/frontend/packages/suse-extensions/src/custom/helm/create-release/create-release.component.scss deleted file mode 100644 index a2974cf6f0..0000000000 --- a/src/frontend/packages/suse-extensions/src/custom/helm/create-release/create-release.component.scss +++ /dev/null @@ -1,50 +0,0 @@ -:host { - flex: 1; -} - -.helm-create-release { - &__heading { - align-items: center; - display: flex; - } - - &__title { - flex: 1; - font-size: 14px; - } - &__button { - height: 36px; - } -} - -form { - flex: 1; - - mat-checkbox { - display: flex; - height: 23px; - margin-top: 10px; - } -} - -.overrides { - &__yaml { - background-color: rgba(0, 0, 0, .1); - font-family: 'Source Code Pro', monospace; - height: 400px; - } - - &_form { - max-width: 100%; - } - - &_form-field { - flex: 1; - height: 100%; - width: 100%; - } -} - -form.overrides_form { - max-width: 100%; -} \ No newline at end of file diff --git a/src/frontend/packages/suse-extensions/src/custom/helm/create-release/create-release.module.ts b/src/frontend/packages/suse-extensions/src/custom/helm/create-release/create-release.module.ts deleted file mode 100644 index 924e790d1e..0000000000 --- a/src/frontend/packages/suse-extensions/src/custom/helm/create-release/create-release.module.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { NgModule } from '@angular/core'; - -import { CoreModule } from '../../../../../core/src/core/core.module'; -import { SharedModule } from '../../../../../core/src/shared/shared.module'; -import { CreateReleaseComponent } from './create-release.component'; - - -@NgModule({ - imports: [ - CommonModule, - CoreModule, - SharedModule - ], - declarations: [ - CreateReleaseComponent, - ] -}) -export class CreateReleaseModule { } diff --git a/src/frontend/packages/suse-extensions/src/custom/helm/helm.module.ts b/src/frontend/packages/suse-extensions/src/custom/helm/helm.module.ts index 0bba8cacac..02f818996c 100644 --- a/src/frontend/packages/suse-extensions/src/custom/helm/helm.module.ts +++ b/src/frontend/packages/suse-extensions/src/custom/helm/helm.module.ts @@ -4,7 +4,6 @@ import { NgModule } from '@angular/core'; import { CoreModule } from '../../../../core/src/core/core.module'; import { SharedModule } from '../../../../core/src/shared/shared.module'; import { MonocularChartViewComponent } from './chart-view/monocular.component'; -import { CreateReleaseModule } from './create-release/create-release.module'; import { HelmRoutingModule } from './helm.routing'; import { MonocularChartCardComponent } from './list-types/monocular-chart-card/monocular-chart-card.component'; import { MonocularTabBaseComponent } from './monocular-tab-base/monocular-tab-base.component'; @@ -17,7 +16,6 @@ import { CatalogTabComponent } from './tabs/catalog-tab/catalog-tab.component'; CommonModule, SharedModule, HelmRoutingModule, - CreateReleaseModule, MonocularModule ], declarations: [ diff --git a/src/frontend/packages/suse-extensions/src/custom/helm/helm.routing.ts b/src/frontend/packages/suse-extensions/src/custom/helm/helm.routing.ts index e42207d593..b523106013 100644 --- a/src/frontend/packages/suse-extensions/src/custom/helm/helm.routing.ts +++ b/src/frontend/packages/suse-extensions/src/custom/helm/helm.routing.ts @@ -2,7 +2,6 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { MonocularChartViewComponent } from './chart-view/monocular.component'; -import { CreateReleaseComponent } from './create-release/create-release.component'; import { MonocularTabBaseComponent } from './monocular-tab-base/monocular-tab-base.component'; import { CatalogTabComponent } from './tabs/catalog-tab/catalog-tab.component'; @@ -18,8 +17,6 @@ const monocular: Routes = [ }, { pathMatch: 'full', path: 'charts/:endpoint/:repo/:chartName/:version', component: MonocularChartViewComponent }, { path: 'charts/:endpoint/:repo/:chartName', component: MonocularChartViewComponent }, - { pathMatch: 'full', path: 'install/:endpoint/:repo/:name/:version', component: CreateReleaseComponent }, - { path: 'install/:endpoint/:repo/:name', component: CreateReleaseComponent }, ]; @NgModule({ diff --git a/src/frontend/packages/suse-extensions/src/custom/helm/monocular/chart-details/chart-details-usage/chart-details-usage.component.ts b/src/frontend/packages/suse-extensions/src/custom/helm/monocular/chart-details/chart-details-usage/chart-details-usage.component.ts index 73025fe6ac..0f213b27e8 100644 --- a/src/frontend/packages/suse-extensions/src/custom/helm/monocular/chart-details/chart-details-usage/chart-details-usage.component.ts +++ b/src/frontend/packages/suse-extensions/src/custom/helm/monocular/chart-details/chart-details-usage/chart-details-usage.component.ts @@ -39,7 +39,7 @@ export class ChartDetailsUsageComponent implements OnInit { } get installUrl(): string { - return `/monocular/install/${getMonocularEndpoint(this.route, this.chart)}/${this.chart.id}/${this.currentVersion}`; + return `/workloads/install/${getMonocularEndpoint(this.route, this.chart)}/${this.chart.id}/${this.currentVersion}`; } } diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/chart-values-editor.component.html b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/chart-values-editor.component.html new file mode 100644 index 0000000000..2baa79aaf0 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/chart-values-editor.component.html @@ -0,0 +1,63 @@ + +
+
+
Loading ...
+ +
+
+ + + Form + YAML + + + +
YAML Editor
+
+ +
+ +
+ + + + + + + + + +
+ +
+ + format_list_numbered + + + map + +
+ +
+
+
+
+
+ warning +
+
+
Error - YAML is not valid
+
Use the YAML editor to correct it so the values can be loaded into the form
+
+
+
+ +
+
+ +
+ +
diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/chart-values-editor.component.scss b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/chart-values-editor.component.scss new file mode 100644 index 0000000000..8e28d24ff1 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/chart-values-editor.component.scss @@ -0,0 +1,104 @@ +:host { + display: flex; + width: 100%; +} +.editor { + + $toolbar-height: 40px; + + &-card { + flex: 1; + padding: 0; + } + + &-title { + font-size: 14px; + } + + &-form { + display: block; + height: calc(100% - #{$toolbar-height}); + overflow-y: scroll; + padding: 20px; + width: 100%; + + &.editor-hidden { + display: none; + } + } + + &-loading { + align-items: center; + display: flex; + height: 100%; + justify-content: center; + position: absolute; + width: 100%; + z-index: 100; + + &__msg { + text-align: center; + } + &__progress-bar { + margin-top: 10px; + min-width: 120px; + } + } + + &-yaml-error { + align-items: center; + display: flex; + height: calc(100% - #{$toolbar-height}); + justify-content: center; + margin: -20px; + opacity: 0.7; + position: absolute; + width: 100%; + z-index: 100; + + &__msg { + display: flex; + flex-direction: row; + } + &__text { + line-height: 24px; + } + &__icon { + font-size: 32px; + height: 32px; + margin-right: 12px; + width: 32px; + } + } + + &-toolbar-buttons { + display: flex; + mat-button-toggle { + margin-left: 8px; + } + } + + &-spacer { + flex: 1 1 auto; + } + + &-monaco { + display: block; + + &.editor-hidden { + display: none; + } + } + + &-monaco-edit { + position: absolute; + } + + &-menu-divider { + margin: 4px 0; + } +} + +.mat-card.editor-card>:first-child { + margin: 0; +} \ No newline at end of file diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/chart-values-editor.component.spec.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/chart-values-editor.component.spec.ts new file mode 100644 index 0000000000..8dc9906951 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/chart-values-editor.component.spec.ts @@ -0,0 +1,41 @@ +import { HttpClient, HttpClientModule, HttpHandler } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { createBasicStoreModule } from '@stratosui/store/testing'; + +import { ConfirmationDialogService } from '../../../../../../core/src/shared/components/confirmation-dialog.service'; +import { MDAppModule } from './../../../../../../core/src/core/md.module'; +import { ChartValuesEditorComponent } from './chart-values-editor.component'; + +describe('ChartValuesEditorComponent', () => { + let component: ChartValuesEditorComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ ChartValuesEditorComponent ], + providers: [ + HttpClient, + HttpHandler, + ConfirmationDialogService, + ], + imports: [ + MDAppModule, + HttpClientModule, + HttpClientTestingModule, + createBasicStoreModule(), + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ChartValuesEditorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/chart-values-editor.component.theme.scss b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/chart-values-editor.component.theme.scss new file mode 100644 index 0000000000..fa694c22cd --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/chart-values-editor.component.theme.scss @@ -0,0 +1,65 @@ +@mixin app-chart-values-editor-theme($theme, $app-theme) { + + $background: map-get($theme, background); + $foreground: map-get($theme, foreground); + + $app-background: map-get($app-theme, app-background-color); + + // Fixes some layout issues with Angular Json Schema Form due to use of Angular flex-layout + + // Also tweaks sizing and spacing of elements + + $vert-padding: 10px; + $toolbar-icon-size: 16px; + $toolbar-item-height: 24px; + $toolbar-item-font-size: 12px; + + // We discourage use of !important, but this is only way to override + // the angular flex settings in the ajsf library - otherwise the layout is wrong + .form-flex-column { + flex-flow: column !important; + } + + .legend { + font-size: 14px; + padding: $vert-padding 0; + } + + .editor-loading, .editor-yaml-error { + background-color: $app-background; + } + + // Make controls in the editor toolbar smaller + .editor-toolbar { + height: 40px; + + .mat-button-toggle-button { + display: flex; + } + .mat-button-toggle-label-content { + font-size: $toolbar-item-font-size; + line-height: $toolbar-item-height; + padding: 0 8px; + + .mat-icon { + font-size: $toolbar-icon-size; + height: $toolbar-icon-size; + width: $toolbar-icon-size; + } + } + .mat-button { + font-size: $toolbar-item-font-size; + line-height: $toolbar-item-height; + padding: 0 12px; + } + + } + + // Override hover color for context menu to align with Stratos theme + // Monaco doesn't seem to expose this as a theme colour + .monaco-editor .monaco-menu .action-item.focused a.action-menu-item { + background: mat-color($background, 'hover') !important; + color: mat-color($foreground, 'text') !important; + } + +} diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/chart-values-editor.component.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/chart-values-editor.component.ts new file mode 100644 index 0000000000..9b5f28c925 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/chart-values-editor.component.ts @@ -0,0 +1,427 @@ +import { HttpClient } from '@angular/common/http'; +import { AfterViewInit, Component, ElementRef, Input, OnDestroy, OnInit, Renderer2, ViewChild } from '@angular/core'; +import { JsonSchemaFormComponent } from '@cfstratos/ajsf-core'; +import * as yaml from 'js-yaml'; +import { BehaviorSubject, combineLatest, fromEvent, Observable, of, Subscription } from 'rxjs'; +import { catchError, debounceTime, filter, map, startWith, tap } from 'rxjs/operators'; + +import { ConfirmationDialogConfig } from '../../../../../../core/src/shared/components/confirmation-dialog.config'; +import { ConfirmationDialogService } from '../../../../../../core/src/shared/components/confirmation-dialog.service'; +import { ThemeService } from './../../../../../../store/src/theme.service'; +import { diffObjects } from './diffvalues'; +import { generateJsonSchemaFromObject } from './json-schema-generator'; + + +export interface ChartValuesConfig { + + // URL of the JSON Schema for the chart values + schemaUrl: string; + + // URL of the Chart Values + valuesUrl: string; + + // Values for the current release (optional) + releaseValues? : string +} + +// Height of the toolbar that sits above the editor conmponent +const TOOLBAR_HEIGHT = 40; + +// Editor modes - can be either using the form or the code editor +enum EditorMode { + CodeEditor = 'editor', + JSonSchemaForm = 'form', +} + +@Component({ + selector: 'app-chart-values-editor', + templateUrl: './chart-values-editor.component.html', + styleUrls: ['./chart-values-editor.component.scss'] +}) +export class ChartValuesEditorComponent implements OnInit, OnDestroy, AfterViewInit { + + @Input() set config(config: ChartValuesConfig) { + if (!!config) { + this.schemaUrl = config.schemaUrl; + this.valuesUrl = config.valuesUrl; + this.releaseValues = config.releaseValues; + this.init(); + } + } + + schemaUrl: string; + valuesUrl: string; + releaseValues: string; + + // Model for the editor - we set this once when the YAML support has been loaded + public model; + + // Editor mode - either 'editor' for the Monaco Code Editor or 'form' for the JSON Schema Form editor + public mode: EditorMode = EditorMode.CodeEditor; + + // Content shown in the code editor + public code = ''; + + // JSON Schema + public schema: any; + + public hasSchema = false; + + // Data shown in the form on load + public initialFormData = {}; + + // Data updated in the form as the user changes it + public formData = {}; + + // Is the YAML in the code editor invalid? + public yamlError = false; + + // Monaco Code Editor settings + public minimap = true; + public lineNumbers = true; + + // Chart Values - as both raw text (keeping comments) and parsed JSON + public chartValuesYaml: string; + public chartValues: any; + + // Default Monaco options + public editorOptions = { + automaticLayout: false, // We will resize the editor to fit the available space + contextmenu: false, // Turn off the right-click context menu + tabSize: 2, + }; + + // Monaco editor + public editor: any; + + // Observable - are we still loading resources? + public loading$: Observable; + + // Observable for tracking if the Monaco editor has loaded + private monacoLoaded$ = new BehaviorSubject(false); + + private resizeSub: Subscription; + private themeSub: Subscription; + + // Track whether the user changes the code in the text editor + private codeOnEnter: string; + + // Reference to the editor, so we can adjust its size to fit + @ViewChild('monacoEditor', {read: ElementRef}) monacoEditor: ElementRef; + + @ViewChild('schemaForm') schemaForm: JsonSchemaFormComponent; + + // Confirmation dialog - copy values + overwriteValuesConfirmation = new ConfirmationDialogConfig( + 'Overwrite Values?', + 'Are you sure you want to replace your values with those from values.yaml?', + 'Overwrite' + ); + + // Confirmation dialog - copy release values + overwriteReleaseValuesConfirmation = new ConfirmationDialogConfig( + 'Overwrite Values?', + 'Are you sure you want to replace your values with those from the release?', + 'Overwrite' + ); + + // Confirmation dialog - diff values + overwriteDiffValuesConfirmation = new ConfirmationDialogConfig( + 'Overwrite Values?', + 'Are you sure you want to replace your values with the diff with values.yaml?', + 'Overwrite' + ); + + // Confirmation dialog - clear values + clearValuesConfirmation = new ConfirmationDialogConfig( + 'Clear Values?', + 'Are you sure you want to clear the form values?', + 'Overwrite' + ); + + constructor( + private elRef: ElementRef, + private renderer: Renderer2, + private httpClient: HttpClient, + private themeService: ThemeService, + private confirmDialog: ConfirmationDialogService, + ) {} + + ngOnInit(): void { + // Listen for window resize and resize the editor when this happens + this.resizeSub = fromEvent(window, 'resize').pipe(debounceTime(150)).subscribe(event => this.resize()); + } + + private init() { + // Observabled for loading schema and values for the Chart + const schema$ = this.httpClient.get(this.schemaUrl).pipe(catchError(e => of(null))); + const values$: Observable = this.httpClient.get(this.valuesUrl, { responseType: 'text' }).pipe( + catchError(e => of(null)) + ); + + // We need the schame, value sand the monaco editor to be all loaded before we're ready + this.loading$ = combineLatest(schema$, values$, this.monacoLoaded$).pipe( + filter(([schema, values, loaded]) => schema !== undefined && values !== undefined && loaded), + tap(([schema, values, loaded]) => { + this.schema = schema; + if (values !== null) { + this.chartValuesYaml = values as string; + this.chartValues = yaml.safeLoad(values); + // Set the form to the chart values initially, so if the user does nothing, they get the defaults + this.initialFormData = this.chartValues; + } + // Default to form if there is a schema + if (schema !== null) { + this.hasSchema = true; + this.mode = EditorMode.JSonSchemaForm; + // Register schema with the Monaco editor + this.registerSchema(this.schema); + } else { + // No Schema, so register an auto-generated schema from the Chart's values + this.registerSchema(generateJsonSchemaFromObject('Generated Schema', this.chartValues)); + + // Inherit the previous values if available (upgrade) + if (this.releaseValues) { + this.code = yaml.safeDump(this.releaseValues); + } + } + this.updateModel(); + }), + map(([schema, values, loaded]) => !loaded), + startWith(true) + ); + } + + ngAfterViewInit(): void { + this.resizeEditor(); + } + + ngOnDestroy(): void { + if (this.resizeSub) { + this.resizeSub.unsubscribe(); + } + if (this.themeSub) { + this.themeSub.unsubscribe(); + } + } + + // Toggle editor minimap on/off + toggleMinimap() { + this.minimap = !this.minimap; + this.editor.updateOptions({ minimap: { enabled: this.minimap } }); + } + + // Toggle editor line numbers on/off + toggleLineNumbers() { + this.lineNumbers = !this.lineNumbers; + this.editor.updateOptions({ lineNumbers: this.lineNumbers ? 'on' : 'off' }); + } + + // Store the update form data when the form changes + // AJSF two-way binding seems to cause issues + formChanged(data: any) { + this.formData = data; + } + + // The edit mode has changed (form or editor) + editModeChanged(mode) { + this.mode = mode.value; + + if (this.mode === EditorMode.CodeEditor) { + // Form -> Editor + // Only copy if there is not an error - otherwise keep the invalid yaml from the editor that needs fixing + if (!this.yamlError) { + this.code = this.getDiff(this.formData); + this.codeOnEnter = this.code; + } + + // Need to resize the editor, as it will be freshly shown + this.resizeEditor(); + } else { + // Editor -> Form + // Try and parse the YAML - if we can't this is an error, so we can't edit this back in the form + try { + if (this.codeOnEnter === this.code) { + return + } + + // Parse as json + const json = yaml.safeLoad(this.code || '{}'); + // Must be an object, otherwise it was not valid + if (typeof(json) !== 'object') { + throw new Error('Invalid YAML'); + } + this.yamlError = false; + const data = { + ...this.formData, + ...json + }; + this.initialFormData = data; + this.formData = data; + } catch (e) { + // The yaml in the code editor is invalid, so we can't marshal it back to json for the from editor + this.yamlError = true; + } + } + } + + // Called once the Monaco editor has loaded and then each time the model is update + // Store a reference to the editor and ensure the editor theme is synchronized with the Stratos theme + onMonacoInit(editor) { + this.editor = editor; + this.resize(); + + // Only load the YAML support once - when we set the model, onMonacoInit will et + if (this.model) { + return; + } + + // Load the YAML Language support - require is available as it will have been loaded by the Monaco vs loader + const req = (window as any).require; + req(['vs/language/yaml/monaco.contribution'], () => { + // Set the model now that YAML support is loaded - this will update the editor correctly + this.updateModel(); + this.monacoLoaded$.next(true); + }); + + // Watch for theme changes - set light/dark theme in the monaco editor as the Stratos theme changes + this.themeSub = this.themeService.getTheme().subscribe(theme => { + const monaco = (window as any).monaco; + const monacoTheme = (theme.styleName === 'dark-theme') ? 'vs-dark' : 'vs'; + monaco.editor.setTheme(monacoTheme); + }); + } + + private updateModel() { + this.model = { + language: 'yaml', + uri: this.getSchemaUri() + }; + } + + // Delayed resize of editor to fit + resizeEditor() { + setTimeout(() => this.resize(), 1); + } + + // Resize editor to fit + resize() { + // Return if resize before editor has been set + if (!this.editor) { + return; + } + + // Get width and height of the host element + const w = this.elRef.nativeElement.offsetWidth; + let h = this.elRef.nativeElement.offsetHeight; + + // Check if host element not visible (does not have a size) + if ((w === 0) && (h === 0)) { + return; + } + + // Remove height of toolbar (since this is incluced in the height of the host element) + h = h - TOOLBAR_HEIGHT; + + // Set the Monaco editor to the same size as the container + this.renderer.setStyle(this.monacoEditor.nativeElement, 'width', `${w}px`); + this.renderer.setStyle(this.monacoEditor.nativeElement, 'height', `${h}px`); + + // Ask Monaco to layout again with its new size + this.editor.layout(); + } + + // Get an absolute URI for the Schema - it is not fetched, just used as a reference + // schemaUrl is a relative URL - e.g. /p1/v1/chartsvc.... + getSchemaUri(): string { + return `https://stratos.app/schemas${this.schemaUrl}`; + } + + // Register the schema with the Monaco editor + // Reference: https://github.com/pengx17/monaco-yaml/blob/master/examples/umd/index.html#L69 + registerSchema(schema: any) { + const monaco = (window as any).monaco; + monaco.languages.yaml.yamlDefaults.setDiagnosticsOptions({ + enableSchemaRequest: true, + hover: true, + completion: true, + validate: true, + format: true, + schemas: [ + { + uri: this.getSchemaUri(), + fileMatch: [this.getSchemaUri()], + schema + } + ] + }); + } + + public getValues(): any { + return (this.mode === EditorMode.JSonSchemaForm) ? this.formData : yaml.safeLoad(this.code); + } + + public copyValues() { + const confirm = this.mode === EditorMode.JSonSchemaForm || this.mode === EditorMode.CodeEditor && this.code.length > 0; + if (confirm) { + this.confirmDialog.open(this.overwriteValuesConfirmation, () => { + this.doCopyValues(); + }); + } else { + this.doCopyValues(); + } + } + + // Copy the chart values into either the form or the code editor, depending on the current mode + private doCopyValues() { + if (this.mode === EditorMode.JSonSchemaForm) { + this.initialFormData = this.chartValues; + } else { + // Use the raw Yaml, so we keep comments and formatting + this.code = this.chartValuesYaml; + } + } + + public copyReleaseValues() { + const confirm = this.mode === EditorMode.JSonSchemaForm || this.mode === EditorMode.CodeEditor && this.code.length > 0; + if (confirm) { + this.confirmDialog.open(this.overwriteReleaseValuesConfirmation, () => { + this.doCopyReleaseValues(); + }); + } else { + this.doCopyReleaseValues(); + } + } + + // Copy the release values into either the form or the code editor, depending on the current mode + private doCopyReleaseValues() { + if (this.mode === EditorMode.JSonSchemaForm) { + this.initialFormData = this.releaseValues; + } else { + this.code = yaml.safeDump(this.releaseValues); + } + } + + // Reset the form values + clearFormValues() { + this.confirmDialog.open(this.clearValuesConfirmation, () => { + this.initialFormData = {}; + }); + } + + // Update the code editor to only show the YAML that contains the differences with the values.yaml + diff() { + this.confirmDialog.open(this.overwriteDiffValuesConfirmation, () => { + const userValues = yaml.safeLoad(this.code); + this.code = this.getDiff(userValues); + }); + } + + getDiff(userValues: any): string { + let code = yaml.safeDump(diffObjects(userValues, this.chartValues)); + if (code.trim() === '{}') { + code = ''; + } + return code + } +} diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/diffvalues.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/diffvalues.ts new file mode 100644 index 0000000000..4fc2d15946 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/diffvalues.ts @@ -0,0 +1,50 @@ +// Helper for diffing user values and chart values + +function arraysAreEqual(a1: any[], a2: any[]): boolean { + if (a1.length !== a2.length) { + return false + } + + for (let i=0; i { + if (typeof(src[key]) === typeof(dest[key])) { + if(src[key] === null && dest[key] === null) { + delete src[key]; + } else if(Array.isArray(src[key])) { + // Array + if (arraysAreEqual(src[key], dest[key])) { + delete src[key]; + } + } else if (typeof(src[key]) === 'object') { + // Object + diffObjects(src[key], dest[key]); + if (src[key] && Object.keys(src[key]).length === 0) { + delete src[key]; + } + } else if (src[key] === dest[key]) { + // Value + delete src[key]; + } + } + }); + return src; +} \ No newline at end of file diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/json-schema-generator.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/json-schema-generator.ts new file mode 100644 index 0000000000..531d3aa985 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/chart-values-editor/json-schema-generator.ts @@ -0,0 +1,288 @@ +// Generate a JSON Schema from an object +// This code incorporates the library: https://github.com/nijikokun/generate-schema/blob/master/src/schemas/json.js +// It is modified for Typescript and to mark all properties as not required + +// Reference: https://github.com/stephenhandley/type-of-is/blob/master/index.js +// Modified for Typescript + +const BUILT_IN_TYPES = [ + Object, + Function, + Array, + String, + Boolean, + Number, + Date, + RegExp, + Error +]; + +const _toString = ({}).toString; + +function isBuiltIn(_constructor): boolean { + for (const bit of BUILT_IN_TYPES) { + if (bit === _constructor) { + return true; + } + } + return false; +}; + +function of(obj) { + if ((obj === null) || (obj === undefined)) { + return obj; + } else { + return obj.constructor; + } +} + +function stringType(obj) { + // [object Blah] -> Blah + const stype = _toString.call(obj).slice(8, -1); + if ((obj === null) || (obj === undefined)) { + return stype.toLowerCase(); + } + + const ctype = of(obj); + if (ctype && !isBuiltIn(ctype)) { + return ctype.name; + } else { + return stype; + } +}; + +// Reference: https://github.com/nijikokun/generate-schema/blob/master/src/schemas/json.js + + +const DRAFT = 'http://json-schema.org/draft-04/schema#' + +function getPropertyFormat(value) { + const type = stringType(value).toLowerCase() + + if (type === 'date') return 'date-time' + return null +} + +function getPropertyType(value) { + const type = stringType(value).toLowerCase() + + if (type === 'number') return Number.isInteger(value) ? 'integer' : type + if (type === 'date') return 'string' + if (type === 'regexp') return 'string' + if (type === 'function') return 'string' + return type +} + +function getUniqueKeys(a, b, c) { + a = Object.keys(a) + b = Object.keys(b) + c = c || [] + + let value + let cIndex + let aIndex + + for (let keyIndex = 0, keyLength = b.length; keyIndex < keyLength; keyIndex++) { + value = b[keyIndex] + aIndex = a.indexOf(value) + cIndex = c.indexOf(value) + + if (aIndex === -1) { + if (cIndex !== -1) { + // Value is optional, it doesn't exist in A but exists in B(n) + c.splice(cIndex, 1) + } + } else if (cIndex === -1) { + // Value is required, it exists in both B and A, and is not yet present in C + c.push(value) + } + } + + return c +} + +function processArray(array, output?, nested?: boolean) { + let format + let oneOf + let type + + if (nested && output) { + output = { items: output } + } else { + output = output || {} + output.type = getPropertyType(array) + output.items = output.items || {} + type = output.items.type || null + } + + // Determine whether each item is different + for (let arrIndex = 0, arrLength = array.length; arrIndex < arrLength; arrIndex++) { + const elementType = getPropertyType(array[arrIndex]) + const elementFormat = getPropertyFormat(array[arrIndex]) + + if (type && elementType !== type) { + output.items.oneOf = [] + oneOf = true + break + } else { + type = elementType + format = elementFormat + } + } + + // Setup type otherwise + if (!oneOf && type) { + output.items.type = type + if (format) { + output.items.format = format + } + } else if (oneOf && type !== 'object') { + output.items = { + oneOf: [{ type }], + required: false + } + } + + // Process each item depending + if (typeof output.items.oneOf !== 'undefined' || type === 'object') { + for (let itemIndex = 0, itemLength = array.length; itemIndex < itemLength; itemIndex++) { + const value = array[itemIndex] + const itemType = getPropertyType(value) + const itemFormat = getPropertyFormat(value) + let arrayItem + if (itemType === 'object') { + if (output.items.properties) { + output.items.required = false + } + arrayItem = processObject(value, oneOf ? {} : output.items.properties, true) + } else if (itemType === 'array') { + arrayItem = processArray(value, oneOf ? {} : output.items.properties, true) + } else { + arrayItem = {} + arrayItem.type = itemType + if (itemFormat) { + arrayItem.format = itemFormat + } + } + if (oneOf) { + const childType = stringType(value).toLowerCase() + const tempObj: any = {}; + if (!arrayItem.type && childType === 'object') { + tempObj.properties = arrayItem + tempObj.type = 'object' + arrayItem = tempObj + } + output.items.oneOf.push(arrayItem) + } else { + if (output.items.type !== 'object') { + continue; + } + output.items.properties = arrayItem + } + } + } + return nested ? output.items : output +} + +function processObject(object: any, output?: any, nested?: boolean) { + if (nested && output) { + output = { properties: output } + } else { + output = output || {} + output.type = getPropertyType(object) + output.properties = output.properties || {} + output.required = [] + } + + for(const key of Object.keys(object)) { + const value = object[key] + let typ = getPropertyType(value) + const format = getPropertyFormat(value) + + typ = typ === 'undefined' ? 'null' : typ + + if (typ === 'object') { + output.properties[key] = processObject(value, output.properties[key]) + continue + } + + if (typ === 'array') { + output.properties[key] = processArray(value, output.properties[key]) + continue + } + + if (output.properties[key]) { + const entry = output.properties[key] + const hasTypeArray = Array.isArray(entry.type) + + // When an array already exists, we check the existing + // type array to see if it contains our current property + // type, if not, we add it to the array and continue + if (hasTypeArray && entry.type.indexOf(typ) < 0) { + entry.type.push(typ) + } + + // When multiple fields of differing types occur, + // json schema states that the field must specify the + // primitive types the field allows in array format. + if (!hasTypeArray && entry.type !== typ) { + entry.type = [entry.type, typ] + } + + continue + } + + output.properties[key] = {} + output.properties[key].type = typ + + if (format) { + output.properties[key].format = format + } + } + + return nested ? output.properties : output +} + + +export function generateJsonSchemaFromObject(title, object) { + let processOutput + const output: any = { + $schema: DRAFT + } + + // Determine title exists + if (typeof title !== 'string') { + object = title + title = undefined + } else { + output.title = title + } + + // Set initial object type + output.type = stringType(object).toLowerCase() + + // Process object + if (output.type === 'object') { + processOutput = processObject(object) + output.type = processOutput.type + output.properties = processOutput.properties + + // For a generated schema, nothing is marked as required + // This is a modification to the library + output.required = false; + } + + if (output.type === 'array') { + processOutput = processArray(object) + output.type = processOutput.type + output.items = processOutput.items + + if (output.title) { + output.items.title = output.title + output.title += ' Set' + } + } + + // Output + return output +} \ No newline at end of file diff --git a/src/frontend/packages/suse-extensions/src/custom/helm/create-release/create-release.component.html b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/create-release/create-release.component.html similarity index 70% rename from src/frontend/packages/suse-extensions/src/custom/helm/create-release/create-release.component.html rename to src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/create-release/create-release.component.html index 4aec9bb603..000f286a25 100644 --- a/src/frontend/packages/suse-extensions/src/custom/helm/create-release/create-release.component.html +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/create-release/create-release.component.html @@ -36,17 +36,6 @@ -
-
-

Enter YAML Value Overrides

- -
- - Values - - -
+
\ No newline at end of file diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/create-release/create-release.component.scss b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/create-release/create-release.component.scss new file mode 100644 index 0000000000..cf807defaa --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/create-release/create-release.component.scss @@ -0,0 +1,18 @@ +:host { + flex: 1; +} + +.helm-create-release { + &__heading { + align-items: center; + display: flex; + } + + &__title { + flex: 1; + font-size: 14px; + } + &__button { + height: 36px; + } +} diff --git a/src/frontend/packages/suse-extensions/src/custom/helm/create-release/create-release.component.spec.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/create-release/create-release.component.spec.ts similarity index 61% rename from src/frontend/packages/suse-extensions/src/custom/helm/create-release/create-release.component.spec.ts rename to src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/create-release/create-release.component.spec.ts index c214981990..f2533edc8b 100644 --- a/src/frontend/packages/suse-extensions/src/custom/helm/create-release/create-release.component.spec.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/create-release/create-release.component.spec.ts @@ -3,15 +3,15 @@ import { HttpClient } from '@angular/common/http'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { ConfirmationDialogService } from '../../../../../core/src/shared/components/confirmation-dialog.service'; -import { TabNavService } from '../../../../../core/tab-nav.service'; -import { EntityMonitorFactory } from '../../../../../store/src/monitors/entity-monitor.factory.service'; -import { InternalEventMonitorFactory } from '../../../../../store/src/monitors/internal-event-monitor.factory'; -import { PaginationMonitorFactory } from '../../../../../store/src/monitors/pagination-monitor.factory'; -import { KubernetesBaseTestModules } from '../../kubernetes/kubernetes.testing.module'; -import { MockChartService } from '../monocular/shared/services/chart.service.mock'; -import { ChartsService } from '../monocular/shared/services/charts.service'; -import { ConfigService } from '../monocular/shared/services/config.service'; +import { ConfirmationDialogService } from '../../../../../../core/src/shared/components/confirmation-dialog.service'; +import { TabNavService } from '../../../../../../core/tab-nav.service'; +import { EntityMonitorFactory } from '../../../../../../store/src/monitors/entity-monitor.factory.service'; +import { InternalEventMonitorFactory } from '../../../../../../store/src/monitors/internal-event-monitor.factory'; +import { PaginationMonitorFactory } from '../../../../../../store/src/monitors/pagination-monitor.factory'; +import { MockChartService } from '../../../helm/monocular/shared/services/chart.service.mock'; +import { ChartsService } from '../../../helm/monocular/shared/services/charts.service'; +import { ConfigService } from '../../../helm/monocular/shared/services/config.service'; +import { KubernetesBaseTestModules } from '../../kubernetes.testing.module'; import { CreateReleaseComponent } from './create-release.component'; describe('CreateReleaseComponent', () => { @@ -52,8 +52,6 @@ describe('CreateReleaseComponent', () => { }); it('should be created', () => { - httpMock.expectOne('/pp/v1/chartsvc/v1/assets/undefined/undefined/versions/undefined/values.yaml'); - expect(component).toBeTruthy(); }); diff --git a/src/frontend/packages/suse-extensions/src/custom/helm/create-release/create-release.component.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/create-release/create-release.component.ts similarity index 73% rename from src/frontend/packages/suse-extensions/src/custom/helm/create-release/create-release.component.ts rename to src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/create-release/create-release.component.ts index 372ea2e145..fcc3ccff84 100644 --- a/src/frontend/packages/suse-extensions/src/custom/helm/create-release/create-release.component.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/create-release/create-release.component.ts @@ -1,27 +1,26 @@ -import { HttpClient } from '@angular/common/http'; import { Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { FormControl, FormGroup, Validators } from '@angular/forms'; -import { MatTextareaAutosize } from '@angular/material/input'; import { ActivatedRoute } from '@angular/router'; import { BehaviorSubject, combineLatest, Observable, of, Subscription } from 'rxjs'; import { distinctUntilChanged, filter, first, map, pairwise, startWith, switchMap } from 'rxjs/operators'; -import { EndpointsService } from '../../../../../core/src/core/endpoints.service'; -import { safeUnsubscribe } from '../../../../../core/src/core/utils.service'; -import { ConfirmationDialogConfig } from '../../../../../core/src/shared/components/confirmation-dialog.config'; -import { ConfirmationDialogService } from '../../../../../core/src/shared/components/confirmation-dialog.service'; -import { StepOnNextFunction, StepOnNextResult } from '../../../../../core/src/shared/components/stepper/step/step.component'; -import { RequestInfoState } from '../../../../../store/src/reducers/api-request-reducer/types'; -import { kubeEntityCatalog } from '../../kubernetes/kubernetes-entity-catalog'; -import { KUBERNETES_ENDPOINT_TYPE } from '../../kubernetes/kubernetes-entity-factory'; -import { KubernetesNamespace } from '../../kubernetes/store/kube.types'; -import { getFirstChartUrl } from '../../kubernetes/workloads/workload.utils'; -import { helmEntityCatalog } from '../helm-entity-catalog'; -import { createMonocularProviders } from '../monocular/stratos-monocular-providers.helpers'; -import { getMonocularEndpoint, stratosMonocularEndpointGuid } from '../monocular/stratos-monocular.helper'; -import { HelmInstallValues } from '../store/helm.types'; -import { ChartsService } from './../monocular/shared/services/charts.service'; -import { HelmChartReference } from './../store/helm.types'; +import { EndpointsService } from '../../../../../../core/src/core/endpoints.service'; +import { safeUnsubscribe } from '../../../../../../core/src/core/utils.service'; +import { + StepOnNextFunction, + StepOnNextResult, +} from '../../../../../../core/src/shared/components/stepper/step/step.component'; +import { RequestInfoState } from '../../../../../../store/src/reducers/api-request-reducer/types'; +import { helmEntityCatalog } from '../../../helm/helm-entity-catalog'; +import { ChartsService } from '../../../helm/monocular/shared/services/charts.service'; +import { createMonocularProviders } from '../../../helm/monocular/stratos-monocular-providers.helpers'; +import { getMonocularEndpoint, stratosMonocularEndpointGuid } from '../../../helm/monocular/stratos-monocular.helper'; +import { HelmChartReference, HelmInstallValues } from '../../../helm/store/helm.types'; +import { kubeEntityCatalog } from '../../kubernetes-entity-catalog'; +import { KUBERNETES_ENDPOINT_TYPE } from '../../kubernetes-entity-factory'; +import { KubernetesNamespace } from '../../store/kube.types'; +import { getFirstChartUrl } from '../workload.utils'; +import { ChartValuesConfig, ChartValuesEditorComponent } from './../chart-values-editor/chart-values-editor.component'; @Component({ selector: 'app-create-release', @@ -33,13 +32,6 @@ import { HelmChartReference } from './../store/helm.types'; }) export class CreateReleaseComponent implements OnInit, OnDestroy { - // Confirmation dialog - overwriteValuesConfirmation = new ConfirmationDialogConfig( - 'Overwrite Values?', - 'Are you sure you want to replace your values with those from values.yaml?', - 'Overwrite' - ); - // isLoading$ = observableOf(false); paginationStateSub: Subscription; @@ -49,47 +41,33 @@ export class CreateReleaseComponent implements OnInit, OnDestroy { details: FormGroup; namespaces$: Observable; - overrides: FormGroup; private endpointChanged = new BehaviorSubject(null); @ViewChild('releaseNameInputField', { static: true }) releaseNameInputField: ElementRef; - @ViewChild('overridesYamlTextArea', { static: true }) overridesYamlTextArea: ElementRef; - @ViewChild(MatTextareaAutosize, { static: false }) overridesYamlAutosize: MatTextareaAutosize; - - public valuesYaml = ''; + @ViewChild('editor', { static: true }) editor: ChartValuesEditorComponent; private subs: Subscription[] = []; private createdNamespace = false; private chart: HelmChartReference; + public config: ChartValuesConfig; constructor( private route: ActivatedRoute, public endpointsService: EndpointsService, - private httpClient: HttpClient, - private confirmDialog: ConfirmationDialogService, private chartsService: ChartsService, ) { const chart = this.route.snapshot.params as HelmChartReference; this.cancelUrl = `/monocular/charts/${getMonocularEndpoint(this.route)}/${chart.repo}/${chart.name}/${chart.version}`; this.chart = chart; - this.setupDetailsStep(); - - this.overrides = new FormGroup({ - values: new FormControl('') - }); - - // Fetch the values.yaml for the Chart - const valuesYamlUrl = `/pp/v1/chartsvc/v1/assets/${chart.repo}/${chart.name}/versions/${chart.version}/values.yaml`; + this.config = { + valuesUrl: `/pp/v1/chartsvc/v1/assets/${chart.repo}/${chart.name}/versions/${chart.version}/values.yaml`, + schemaUrl: `/pp/v1/chartsvc/v1/assets/${chart.repo}/${chart.name}/versions/${chart.version}/values.schema.json`, + } - this.httpClient.get(valuesYamlUrl, { responseType: 'text' }) - .subscribe(response => { - this.valuesYaml = response; - }, err => { - console.error('Failed to fetch chart values: ', err.message || err); - }); + this.setupDetailsStep(); } private setupDetailsStep() { @@ -186,21 +164,6 @@ export class CreateReleaseComponent implements OnInit, OnDestroy { return lowerCase.length ? namespaces.filter(ns => ns.toLowerCase().indexOf(lowerCase) >= 0) : namespaces; } - public useValuesYaml() { - if (this.overrides.value.values.length !== 0) { - this.confirmDialog.open(this.overwriteValuesConfirmation, () => { - this.replaceWithValuesYaml(); - }); - - } else { - this.replaceWithValuesYaml(); - } - } - - private replaceWithValuesYaml() { - this.overrides.controls.values.setValue(this.valuesYaml, { onlySelf: true }); - } - ngOnInit() { // Auto select endpoint if there is only one this.kubeEndpoints$.pipe(first()).subscribe(ep => { @@ -214,11 +177,9 @@ export class CreateReleaseComponent implements OnInit, OnDestroy { }); } + // Ensure the editor is resized when the overrides step becomes visible onEnterOverrides = () => { - setTimeout(() => { - // this.overridesYamlAutosize.resizeToFitContent(true); - this.overridesYamlTextArea.nativeElement.focus(); - }, 1); + this.editor.resizeEditor(); }; submit: StepOnNextFunction = () => { @@ -261,7 +222,7 @@ export class CreateReleaseComponent implements OnInit, OnDestroy { // Build the request body const values: HelmInstallValues = { ...this.details.value, - ...this.overrides.value, + values: this.editor.getValues(), chart: { name: this.route.snapshot.params.name, repo: this.route.snapshot.params.repo, diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.ts index 0a8fbac769..a3f7b44bb4 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.ts @@ -122,6 +122,7 @@ export class HelmReleaseSummaryTabComponent implements OnDestroy { this.hasUpgrade$ = this.helmReleaseHelper.hasUpgrade().pipe(map(v => v ? v.version : null)); + // Can upgrade if the Chart is available this.canUpgrade$ = this.helmReleaseHelper.hasUpgrade(true).pipe(map(v => !!v)); this.resources$ = combineLatest( diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/upgrade-release/upgrade-release.component.html b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/upgrade-release/upgrade-release.component.html index dae9721887..4a3ab48abc 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/upgrade-release/upgrade-release.component.html +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/upgrade-release/upgrade-release.component.html @@ -2,29 +2,11 @@ Upgrade Workload - + - -
-
-
-

Enter YAML Value Overrides

- -
- - Values - - -
- -
+ + -
\ No newline at end of file diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/upgrade-release/upgrade-release.component.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/upgrade-release/upgrade-release.component.ts index 328ded2c6f..2502205f7e 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/upgrade-release/upgrade-release.component.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/upgrade-release/upgrade-release.component.ts @@ -1,14 +1,18 @@ -import { Component } from '@angular/core'; -import { FormControl, FormGroup } from '@angular/forms'; +import { Component, ViewChild } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Store } from '@ngrx/store'; import { Observable, of } from 'rxjs'; -import { filter, first, map, pairwise } from 'rxjs/operators'; +import { filter, first, map, pairwise, tap } from 'rxjs/operators'; -import { StepComponent, StepOnNextFunction } from '../../../../../../core/src/shared/components/stepper/step/step.component'; +import { + StepComponent, + StepOnNextFunction, + StepOnNextResult, +} from '../../../../../../core/src/shared/components/stepper/step/step.component'; import { ActionState } from '../../../../../../store/src/reducers/api-request-reducer/types'; import { stratosMonocularEndpointGuid } from '../../../helm/monocular/stratos-monocular.helper'; import { HelmUpgradeValues, MonocularVersion } from '../../../helm/store/helm.types'; +import { ChartValuesConfig, ChartValuesEditorComponent } from '../chart-values-editor/chart-values-editor.component'; import { HelmReleaseHelperService } from '../release/tabs/helm-release-helper.service'; import { HelmReleaseGuid } from '../workload.types'; import { getFirstChartUrl } from '../workload.utils'; @@ -34,11 +38,14 @@ import { ReleaseUpgradeVersionsListConfig } from './release-version-list-config' }) export class UpgradeReleaseComponent { + @ViewChild('editor', { static: true }) editor: ChartValuesEditorComponent; + public cancelUrl; public listConfig: ReleaseUpgradeVersionsListConfig; public validate$: Observable; private version: MonocularVersion; - public overrides: FormGroup; + + public config: ChartValuesConfig; private monocularEndpointId: string; @@ -52,11 +59,6 @@ export class UpgradeReleaseComponent { this.cancelUrl = `/workloads/${this.helper.guid}`; - // Form for overrides step (Helm Values) - this.overrides = new FormGroup({ - values: new FormControl('') - }); - this.helper.hasUpgrade(true).pipe( filter(c => !!c), first() @@ -80,6 +82,32 @@ export class UpgradeReleaseComponent { }); } + // Ensure the editor is resized when the overrides step becomes visible + onEnterOverrides = () => { + this.editor.resizeEditor(); + } + + // Update the editor with the chosen version when the user moves to the next step + onNext = (): Observable => { + const chart = this.version.relationships.chart.data; + const version = this.version.attributes.version; + + // Fetch the release metadata so that we have the values used to install the current release + return this.helper.release$.pipe( + first(), + tap(release => { + this.config = { + schemaUrl: `/pp/v1/chartsvc/v1/assets/${chart.repo.name}/${chart.name}/versions/${version}/values.schema.json`, + valuesUrl: `/pp/v1/chartsvc/v1/assets/${chart.repo.name}/${chart.name}/versions/${version}/values.yaml`, + releaseValues: release.config + }; + }), + map(() => { + return { success: true } + }) + ); + } + // Hide/show the advanced options step toggleAdvancedOptions() { this.showAdvancedOptions = !this.showAdvancedOptions; @@ -93,7 +121,7 @@ export class UpgradeReleaseComponent { // Add the chart url into the values const values: HelmUpgradeValues = { - values: this.overrides.controls.values.value, + values: this.editor.getValues(), restartPods: false, chart: { name: this.version.relationships.chart.data.name, diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workloads.module.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workloads.module.ts index 44ab5ce4bb..7359657da2 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workloads.module.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workloads.module.ts @@ -1,10 +1,14 @@ import { CommonModule, DatePipe } from '@angular/common'; import { NgModule } from '@angular/core'; +import { MaterialDesignFrameworkModule } from '@cfstratos/ajsf-material'; import { NgxGraphModule } from '@swimlane/ngx-graph'; +import { MonacoEditorModule, NgxMonacoEditorConfig } from 'ngx-monaco-editor'; import { CoreModule } from '../../../../../core/src/core/core.module'; import { SharedModule } from '../../../../../core/src/shared/shared.module'; import { KubernetesModule } from '../kubernetes.module'; +import { ChartValuesEditorComponent } from './chart-values-editor/chart-values-editor.component'; +import { CreateReleaseComponent } from './create-release/create-release.component'; import { HelmReleaseCardComponent } from './list-types/helm-release-card/helm-release-card.component'; import { HelmReleaseTabBaseComponent } from './release/helm-release-tab-base/helm-release-tab-base.component'; import { @@ -25,6 +29,12 @@ import { WorkloadsRouting } from './workloads.routing'; import { HelmReleaseHistoryTabComponent } from './release/tabs/helm-release-history-tab/helm-release-history-tab.component'; import { WorkloadLiveReloadComponent } from './release/workload-live-reload/workload-live-reload.component'; +// Default config for the Monaco edfior +const monacoConfig: NgxMonacoEditorConfig = { + baseUrl: '/core/assets', // configure base path for monaco editor + defaultOptions: { scrollBeyondLastLine: false } +}; + @NgModule({ imports: [ CoreModule, @@ -34,6 +44,8 @@ import { WorkloadLiveReloadComponent } from './release/workload-live-reload/work WorkloadsRouting, NgxGraphModule, KubernetesModule, + MaterialDesignFrameworkModule, + MonacoEditorModule.forRoot(monacoConfig), ], declarations: [ HelmReleasesTabComponent, @@ -46,6 +58,8 @@ import { WorkloadLiveReloadComponent } from './release/workload-live-reload/work HelmReleaseResourceGraphComponent, HelmReleaseCardComponent, HelmReleaseAnalysisTabComponent, + ChartValuesEditorComponent, + CreateReleaseComponent, WorkloadLiveReloadComponent, UpgradeReleaseComponent, HelmReleaseHistoryTabComponent, diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workloads.routing.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workloads.routing.ts index a9773315f8..5b91cda06a 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workloads.routing.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workloads.routing.ts @@ -2,6 +2,7 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { NgxChartsModule } from '@swimlane/ngx-charts'; +import { CreateReleaseComponent } from './create-release/create-release.component'; import { HelmReleaseTabBaseComponent } from './release/helm-release-tab-base/helm-release-tab-base.component'; import { HelmReleaseAnalysisTabComponent, @@ -27,6 +28,8 @@ const routes: Routes = [ component: HelmReleasesTabComponent, pathMatch: 'full', }, + { pathMatch: 'full', path: 'install/:endpoint/:repo/:name/:version', component: CreateReleaseComponent }, + { pathMatch: 'full', path: 'install/:endpoint/:repo/:name', component: CreateReleaseComponent }, { // guid = kube endpoint path: ':guid/upgrade',