diff --git a/backend/apps/v1beta1/routes/__init__.py b/backend/apps/v1beta1/routes/__init__.py index a7bdedd..2b99d6e 100644 --- a/backend/apps/v1beta1/routes/__init__.py +++ b/backend/apps/v1beta1/routes/__init__.py @@ -3,4 +3,4 @@ bp = Blueprint("default_routes", __name__) -from . import post # noqa: F401, E402 +from . import post, put # noqa: F401, E402 diff --git a/backend/apps/v1beta1/routes/put.py b/backend/apps/v1beta1/routes/put.py new file mode 100644 index 0000000..0619f1f --- /dev/null +++ b/backend/apps/v1beta1/routes/put.py @@ -0,0 +1,38 @@ +from flask import request + +from kubeflow.kubeflow.crud_backend import api, decorators, logging + +from ...common import versions +from . import bp + +log = logging.getLogger(__name__) + + +@bp.route("/api/namespaces//inferenceservices/", + methods=["PUT"]) +@decorators.request_is_json_type +@decorators.required_body_params("apiVersion", "kind", "metadata", "spec") +def replace_inference_service(namespace: str, isvc: str): + gvk = versions.inference_service_gvk() + api.authz.ensure_authorized( + "update", + group=gvk["group"], + version=gvk["version"], + resource=gvk["kind"], + namespace=namespace, + ) + + cr = request.get_json() + + api.custom_api.replace_namespaced_custom_object( + group=gvk["group"], + version=gvk["version"], + plural=gvk["kind"], + namespace=namespace, + name=isvc, + body=cr) + + return api.success_response( + "message", + "InferenceService successfully updated" + ) diff --git a/frontend/src/app/pages/server-info/edit/edit.component.html b/frontend/src/app/pages/server-info/edit/edit.component.html new file mode 100644 index 0000000..5859690 --- /dev/null +++ b/frontend/src/app/pages/server-info/edit/edit.component.html @@ -0,0 +1,40 @@ +
+ + + The InferenceService name and namespace fields have been hidden because they cannot be changed + + + + + +
+ + + +
+ + + + + +
+
+ diff --git a/frontend/src/app/pages/server-info/edit/edit.component.scss b/frontend/src/app/pages/server-info/edit/edit.component.scss new file mode 100644 index 0000000..2ef5141 --- /dev/null +++ b/frontend/src/app/pages/server-info/edit/edit.component.scss @@ -0,0 +1,42 @@ +[ace-editor] { + width: auto; + height: 700px; +} + +.edit-component { + margin-top: 1rem; +} + +.edit-top-divider { + margin-top: 1rem; +} + +.bar { + padding: 0.5rem 0; +} + +.bar > * { + margin-top: auto; + margin-bottom: auto; +} + +.bar > button:first-child { + margin-left: 35%; +} + +.bar > button:nth-child(2) { + margin-left: 1rem; + margin-right: 1rem; +} + +.text-area > * { + white-space: break-spaces; +} + +.waiting-button-wrapper { + display: flex; +} + +.waiting-button-wrapper .mat-spinner { + margin: auto 0.2rem; +} diff --git a/frontend/src/app/pages/server-info/edit/edit.component.ts b/frontend/src/app/pages/server-info/edit/edit.component.ts new file mode 100644 index 0000000..317e5fe --- /dev/null +++ b/frontend/src/app/pages/server-info/edit/edit.component.ts @@ -0,0 +1,120 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { dump, load } from 'js-yaml'; +import { SnackBarService, SnackType } from 'kubeflow'; +import { InferenceServiceK8s } from '../../../types/kfserving/v1beta1'; +import { MWABackendService } from '../../../services/backend.service'; + +@Component({ + selector: 'app-edit', + templateUrl: './edit.component.html', + styleUrls: ['./edit.component.scss'], +}) +export class EditComponent implements OnInit { + @Input() isvc: InferenceServiceK8s; + @Output() cancelEdit = new EventEmitter(); + + private originalName: string; + private originalNamespace: string; + private resourceVersion: string; + + data = ''; + applying = false; + + constructor( + private snack: SnackBarService, + private backend: MWABackendService, + ) {} + + ngOnInit() { + this.originalName = this.isvc.metadata.name; + this.originalNamespace = this.isvc.metadata.namespace; + this.resourceVersion = this.isvc.metadata.resourceVersion; + + delete this.isvc.metadata.name; + delete this.isvc.metadata.namespace; + delete this.isvc.metadata.creationTimestamp; + delete this.isvc.metadata.finalizers; + delete this.isvc.metadata.generation; + delete this.isvc.metadata.managedFields; + delete this.isvc.metadata.resourceVersion; + delete this.isvc.metadata.selfLink; + if ('annotations' in this.isvc.metadata && 'kubectl.kubernetes.io/last-applied-configuration' in this.isvc.metadata.annotations) { + delete this.isvc.metadata.annotations['kubectl.kubernetes.io/last-applied-configuration']; + } + delete this.isvc.metadata.uid; + delete this.isvc.status; + + this.data = dump(this.isvc); + } + + submit() { + this.applying = true; + + let cr: InferenceServiceK8s = {}; + try { + cr = load(this.data); + } catch (e) { + let msg = 'Could not parse the provided YAML'; + + if (e.mark && e.mark.line) { + msg = 'Error parsing the provided YAML in line: ' + e.mark.line; + } + + this.snack.open(msg, SnackType.Error, 16000); + this.applying = false; + return; + } + + const requiredFields = ['apiVersion', 'kind', 'metadata', 'spec']; + for (const field of requiredFields) { + if (!cr[field]) { + this.snack.open( + 'InferenceService must have a metadata field.', + SnackType.Error, + 8000, + ); + + this.applying = false; + return; + } + } + + const prohibitedFields = ['name', 'namespace']; + for (const field of prohibitedFields) { + if (cr.metadata[field]) { + this.snack.open( + `You cannot set the metadata.${field} field`, + SnackType.Error, + 8000, + ); + + this.applying = false; + return; + } + } + + // Updating a resource requires passing in the resource's current resourceVersion + // so add this back to the cr before sending it off + cr.metadata.resourceVersion = this.resourceVersion; + cr.metadata.name = this.originalName; + cr.metadata.namespace = this.originalNamespace; + + this.backend.editInferenceService( + this.originalNamespace, + this.originalName, + cr) + .subscribe({ + next: () => { + this.snack.open( + 'InferenceService successfully updated', + SnackType.Success, + 8000, + ); + this.cancelEdit.emit(true); + }, + error: () => { + this.applying = false; + }, + }); + } +} diff --git a/frontend/src/app/pages/server-info/edit/edit.module.ts b/frontend/src/app/pages/server-info/edit/edit.module.ts new file mode 100644 index 0000000..b0c5706 --- /dev/null +++ b/frontend/src/app/pages/server-info/edit/edit.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { KubeflowModule } from 'kubeflow'; + +import { EditComponent } from './edit.component'; +import { AceEditorModule } from '@derekbaker/ngx-ace-editor-wrapper'; + + +@NgModule({ + declarations: [EditComponent], + imports: [CommonModule, AceEditorModule, KubeflowModule], + exports: [EditComponent], +}) +export class EditModule {} diff --git a/frontend/src/app/pages/server-info/server-info.component.html b/frontend/src/app/pages/server-info/server-info.component.html index d198391..e84ee15 100644 --- a/frontend/src/app/pages/server-info/server-info.component.html +++ b/frontend/src/app/pages/server-info/server-info.component.html @@ -27,8 +27,14 @@ + + + + + { + // Make a copy of current isvc so polling update doesn't affect editing + this.editingIsvc = {...this.inferenceService}; + this.isEditing = true; + }, + }), new ToolbarButton({ text: $localize`DELETE`, icon: 'delete', @@ -116,6 +127,10 @@ export class ServerInfoComponent implements OnInit, OnDestroy { return getK8sObjectUiStatus(this.inferenceService); } + public cancelEdit() { + this.isEditing = false; + } + public navigateBack() { this.router.navigate(['/']); } @@ -177,7 +192,7 @@ export class ServerInfoComponent implements OnInit, OnDestroy { const components = ['predictor', 'transformer', 'explainer']; const obs: Observable<[string, string, ComponentOwnedObjects]>[] = []; - ['predictor', 'transformer', 'explainer'].forEach(component => { + components.forEach(component => { obs.push(this.getOwnedObjects(svc, component)); }); diff --git a/frontend/src/app/pages/server-info/server-info.module.ts b/frontend/src/app/pages/server-info/server-info.module.ts index 65d5c44..3fdf4ba 100644 --- a/frontend/src/app/pages/server-info/server-info.module.ts +++ b/frontend/src/app/pages/server-info/server-info.module.ts @@ -11,6 +11,7 @@ import { DetailsModule } from './details/details.module'; import { MetricsModule } from './metrics/metrics.module'; import { LogsModule } from './logs/logs.module'; import { YamlsModule } from './yamls/yamls.module'; +import { EditModule } from './edit/edit.module'; import { EventsModule } from './events/events.module'; @NgModule({ @@ -27,6 +28,7 @@ import { EventsModule } from './events/events.module'; MetricsModule, LogsModule, YamlsModule, + EditModule, EventsModule, ], }) diff --git a/frontend/src/app/services/backend.service.ts b/frontend/src/app/services/backend.service.ts index 947b141..697f2c1 100644 --- a/frontend/src/app/services/backend.service.ts +++ b/frontend/src/app/services/backend.service.ts @@ -169,6 +169,21 @@ export class MWABackendService extends BackendService { .pipe(catchError(error => this.handleError(error))); } + /* + * PUT + */ + public editInferenceService( + namespace: string, + name: string, + updatedIsvc: InferenceServiceK8s, + ): Observable { + const url = `api/namespaces/${namespace}/inferenceservices/${name}`; + + return this.http + .put(url, updatedIsvc) + .pipe(catchError(error => this.handleError(error))); + } + /* * DELETE */