vwa(front): Prevent PVCs from being deleted when there is a corresponding notebook (kubeflow/kubeflow#6899)
* vwa(back): Append notebooks using each PVC Append notebooks using each PVC when getting and parsing all PVCs. Refs arrikto/dev#2017 Signed-off-by: Orfeas Kourkakis <orfeas@arrikto.com> Reviewed-by: Kimonas Sotirchos <kimwnasptd@arrikto.com> * vwa(front): Create custom delete action column Create custom delete column component that extends ActionComponent from Kubeflow common code and adds the following functionalities: * Disable the button when a row's notebooks array is not empty * Display an appropriate message including the notebooks' names Refs arrikto/dev#2017 Signed-off-by: Orfeas Kourkakis <orfeas@arrikto.com> Reviewed-by: Kimonas Sotirchos <kimwnasptd@arrikto.com> * vwa(front): Add delete button component in index page Implement custom delete column in the table in VWA's index page. Refs arrikto/dev#2017 Signed-off-by: Orfeas Kourkakis <orfeas@arrikto.com> Reviewed-by: Kimonas Sotirchos <kimwnasptd@arrikto.com> * vwa(front): Add Used by column in volumes table Add a Used by column in volumes table of VWA's index page in order to link to the notebooks that are using each PVC. Refs arrikto/dev#2017 Signed-off-by: Orfeas Kourkakis <orfeas@arrikto.com> Reviewed-by: Kimonas Sotirchos <kimwnasptd@arrikto.com> * vwa(front): Fix integration tests Update mock request for PVCs Refs arrikto/dev#2017 Refs arrikto/dev#2135 Signed-off-by: Orfeas Kourkakis <orfeas@arrikto.com> Reviewed-by: Kimonas Sotirchos <kimwnasptd@arrikto.com> * vwa(front): Fix format error Signed-off-by: Orfeas Kourkakis <orfeas@arrikto.com> * web-apps(front): Fix linting errors Signed-off-by: Orfeas Kourkakis <orfeas@arrikto.com> --------- Signed-off-by: Orfeas Kourkakis <orfeas@arrikto.com>
This commit is contained in:
parent
d2c76a0739
commit
6a02712226
|
|
@ -1,5 +1,12 @@
|
||||||
import { Component, Input, ComponentRef, OnInit } from '@angular/core';
|
import {
|
||||||
import { ComponentValue } from '../types';
|
Component,
|
||||||
|
Input,
|
||||||
|
ComponentRef,
|
||||||
|
OnInit,
|
||||||
|
Output,
|
||||||
|
EventEmitter,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { ActionEvent, ComponentValue } from '../types';
|
||||||
import { ComponentPortal, Portal } from '@angular/cdk/portal';
|
import { ComponentPortal, Portal } from '@angular/cdk/portal';
|
||||||
|
|
||||||
export interface TableColumnComponent {
|
export interface TableColumnComponent {
|
||||||
|
|
@ -32,6 +39,9 @@ export class ComponentValueComponent implements OnInit {
|
||||||
|
|
||||||
@Input() valueDescriptor: ComponentValue;
|
@Input() valueDescriptor: ComponentValue;
|
||||||
|
|
||||||
|
@Output()
|
||||||
|
emitter = new EventEmitter<ActionEvent>();
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.portal = new ComponentPortal(this.valueDescriptor.component);
|
this.portal = new ComponentPortal(this.valueDescriptor.component);
|
||||||
}
|
}
|
||||||
|
|
@ -39,5 +49,10 @@ export class ComponentValueComponent implements OnInit {
|
||||||
onAttach(ref: ComponentRef<TableColumnComponent>) {
|
onAttach(ref: ComponentRef<TableColumnComponent>) {
|
||||||
this.componentRef = ref;
|
this.componentRef = ref;
|
||||||
this.componentRef.instance.element = this.element;
|
this.componentRef.instance.element = this.element;
|
||||||
|
/* eslint-disable */
|
||||||
|
if (this.componentRef.instance?.['emitter']) {
|
||||||
|
this.componentRef.instance['emitter'] = this.emitter;
|
||||||
|
}
|
||||||
|
/* eslint-enable */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,6 @@ import { RouterModule } from '@angular/router';
|
||||||
TableComponent,
|
TableComponent,
|
||||||
ComponentValueComponent,
|
ComponentValueComponent,
|
||||||
],
|
],
|
||||||
exports: [ResourceTableComponent, TableComponent],
|
exports: [ResourceTableComponent, TableComponent, ActionComponent],
|
||||||
})
|
})
|
||||||
export class ResourceTableModule {}
|
export class ResourceTableModule {}
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,7 @@
|
||||||
<lib-component-value
|
<lib-component-value
|
||||||
[element]="element"
|
[element]="element"
|
||||||
[valueDescriptor]="col.value"
|
[valueDescriptor]="col.value"
|
||||||
|
(emitter)="actionTriggered($event)"
|
||||||
></lib-component-value>
|
></lib-component-value>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
|
||||||
|
|
@ -594,7 +594,9 @@ export class TableComponent
|
||||||
|
|
||||||
public actionTriggered(e: ActionEvent) {
|
public actionTriggered(e: ActionEvent) {
|
||||||
// Forward the emitted ActionEvent
|
// Forward the emitted ActionEvent
|
||||||
this.actionsEmitter.emit(e);
|
if (e instanceof ActionEvent) {
|
||||||
|
this.actionsEmitter.emit(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public newButtonTriggered() {
|
public newButtonTriggered() {
|
||||||
|
|
|
||||||
|
|
@ -108,3 +108,7 @@ export * from './lib/status-icon/status-icon.module';
|
||||||
export * from './lib/urls/urls.component';
|
export * from './lib/urls/urls.component';
|
||||||
export * from './lib/urls/urls.module';
|
export * from './lib/urls/urls.module';
|
||||||
export * from './lib/urls/types';
|
export * from './lib/urls/types';
|
||||||
|
|
||||||
|
export * from './lib/icon/icon.component';
|
||||||
|
export * from './lib/icon/icon.module';
|
||||||
|
export * from './lib/resource-table/action/action.component';
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from kubeflow.kubeflow.crud_backend import api, helpers
|
||||||
from . import status
|
from . import status
|
||||||
|
|
||||||
|
|
||||||
def parse_pvc(pvc):
|
def parse_pvc(pvc, notebooks):
|
||||||
"""
|
"""
|
||||||
pvc: client.V1PersistentVolumeClaim
|
pvc: client.V1PersistentVolumeClaim
|
||||||
|
|
||||||
|
|
@ -14,6 +14,7 @@ def parse_pvc(pvc):
|
||||||
except Exception:
|
except Exception:
|
||||||
capacity = pvc.spec.resources.requests["storage"]
|
capacity = pvc.spec.resources.requests["storage"]
|
||||||
|
|
||||||
|
notebooks = get_notebooks_using_pvc(pvc.metadata.name, notebooks)
|
||||||
parsed_pvc = {
|
parsed_pvc = {
|
||||||
"name": pvc.metadata.name,
|
"name": pvc.metadata.name,
|
||||||
"namespace": pvc.metadata.namespace,
|
"namespace": pvc.metadata.namespace,
|
||||||
|
|
@ -22,11 +23,44 @@ def parse_pvc(pvc):
|
||||||
"capacity": capacity,
|
"capacity": capacity,
|
||||||
"modes": pvc.spec.access_modes,
|
"modes": pvc.spec.access_modes,
|
||||||
"class": pvc.spec.storage_class_name,
|
"class": pvc.spec.storage_class_name,
|
||||||
|
"notebooks": notebooks,
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsed_pvc
|
return parsed_pvc
|
||||||
|
|
||||||
|
|
||||||
|
def get_notebooks_using_pvc(pvc, notebooks):
|
||||||
|
"""Return a list of Notebooks that are using the given PVC."""
|
||||||
|
mounted_notebooks = []
|
||||||
|
|
||||||
|
for nb in notebooks:
|
||||||
|
pvcs = get_notebook_pvcs(nb)
|
||||||
|
if pvc in pvcs:
|
||||||
|
mounted_notebooks.append(nb["metadata"]["name"])
|
||||||
|
|
||||||
|
return mounted_notebooks
|
||||||
|
|
||||||
|
|
||||||
|
def get_notebook_pvcs(nb):
|
||||||
|
"""
|
||||||
|
Return a list of PVC names that the given notebook is using.
|
||||||
|
|
||||||
|
If it doesn't use any, then an empty list will be returned.
|
||||||
|
"""
|
||||||
|
pvcs = []
|
||||||
|
if not nb["spec"]["template"]["spec"]["volumes"]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
vols = nb["spec"]["template"]["spec"]["volumes"]
|
||||||
|
for vol in vols:
|
||||||
|
# Check if the volume is a pvc
|
||||||
|
if not vol.get("persistentVolumeClaim"):
|
||||||
|
continue
|
||||||
|
pvcs.append(vol["persistentVolumeClaim"]["claimName"])
|
||||||
|
|
||||||
|
return pvcs
|
||||||
|
|
||||||
|
|
||||||
def get_pods_using_pvc(pvc, namespace):
|
def get_pods_using_pvc(pvc, namespace):
|
||||||
"""
|
"""
|
||||||
Return a list of Pods that are using the given PVC
|
Return a list of Pods that are using the given PVC
|
||||||
|
|
|
||||||
|
|
@ -10,23 +10,27 @@ log = logging.getLogger(__name__)
|
||||||
def get_pvcs(namespace):
|
def get_pvcs(namespace):
|
||||||
# Return the list of PVCs
|
# Return the list of PVCs
|
||||||
pvcs = api.list_pvcs(namespace)
|
pvcs = api.list_pvcs(namespace)
|
||||||
content = [utils.parse_pvc(pvc) for pvc in pvcs.items]
|
notebooks = api.list_notebooks(namespace)["items"]
|
||||||
|
content = [utils.parse_pvc(pvc, notebooks) for pvc in pvcs.items]
|
||||||
|
|
||||||
return api.success_response("pvcs", content)
|
return api.success_response("pvcs", content)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/namespaces/<namespace>/pvcs/<pvc_name>")
|
@bp.route("/api/namespaces/<namespace>/pvcs/<pvc_name>")
|
||||||
def get_pvc(namespace, pvc_name):
|
def get_pvc(namespace, pvc_name):
|
||||||
pvc = api.get_pvc(pvc_name, namespace)
|
pvc = api.get_pvc(pvc_name, namespace)
|
||||||
return api.success_response("pvc", api.serialize(pvc))
|
return api.success_response("pvc", api.serialize(pvc))
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/namespaces/<namespace>/pvcs/<pvc_name>/pods")
|
@bp.route("/api/namespaces/<namespace>/pvcs/<pvc_name>/pods")
|
||||||
def get_pvc_pods(namespace, pvc_name):
|
def get_pvc_pods(namespace, pvc_name):
|
||||||
pods = utils.get_pods_using_pvc(pvc_name, namespace)
|
pods = utils.get_pods_using_pvc(pvc_name, namespace)
|
||||||
|
|
||||||
return api.success_response("pods", api.serialize(pods))
|
return api.success_response("pods", api.serialize(pods))
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/namespaces/<namespace>/pvcs/<pvc_name>/events")
|
@bp.route("/api/namespaces/<namespace>/pvcs/<pvc_name>/events")
|
||||||
def get_pvc_events(namespace, pvc_name):
|
def get_pvc_events(namespace, pvc_name):
|
||||||
events = api.list_pvc_events(namespace, pvc_name).items
|
events = api.list_pvc_events(namespace, pvc_name).items
|
||||||
|
|
||||||
return api.success_response("events", api.serialize(events))
|
return api.success_response("events", api.serialize(events))
|
||||||
|
|
|
||||||
|
|
@ -21,21 +21,25 @@ def get_pvcs(namespace):
|
||||||
|
|
||||||
# Return the list of PVCs and the corresponding Viewer's state
|
# Return the list of PVCs and the corresponding Viewer's state
|
||||||
pvcs = api.list_pvcs(namespace)
|
pvcs = api.list_pvcs(namespace)
|
||||||
content = [utils.parse_pvc(pvc, viewers) for pvc in pvcs.items]
|
notebooks = api.list_notebooks(namespace)["items"]
|
||||||
|
content = [utils.parse_pvc(pvc, viewers, notebooks) for pvc in pvcs.items]
|
||||||
|
|
||||||
return api.success_response("pvcs", content)
|
return api.success_response("pvcs", content)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/namespaces/<namespace>/pvcs/<pvc_name>")
|
@bp.route("/api/namespaces/<namespace>/pvcs/<pvc_name>")
|
||||||
def get_pvc(namespace, pvc_name):
|
def get_pvc(namespace, pvc_name):
|
||||||
pvc = api.get_pvc(pvc_name, namespace)
|
pvc = api.get_pvc(pvc_name, namespace)
|
||||||
return api.success_response("pvc", api.serialize(pvc))
|
return api.success_response("pvc", api.serialize(pvc))
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/namespaces/<namespace>/pvcs/<pvc_name>/pods")
|
@bp.route("/api/namespaces/<namespace>/pvcs/<pvc_name>/pods")
|
||||||
def get_pvc_pods(namespace, pvc_name):
|
def get_pvc_pods(namespace, pvc_name):
|
||||||
pods = get_pods_using_pvc(pvc_name, namespace)
|
pods = get_pods_using_pvc(pvc_name, namespace)
|
||||||
|
|
||||||
return api.success_response("pods", api.serialize(pods))
|
return api.success_response("pods", api.serialize(pods))
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/namespaces/<namespace>/pvcs/<pvc_name>/events")
|
@bp.route("/api/namespaces/<namespace>/pvcs/<pvc_name>/events")
|
||||||
def get_pvc_events(namespace, pvc_name):
|
def get_pvc_events(namespace, pvc_name):
|
||||||
events = api.list_pvc_events(namespace, pvc_name).items
|
events = api.list_pvc_events(namespace, pvc_name).items
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ def add_pvc_rok_annotations(pvc, body):
|
||||||
pvc.metadata.labels = labels
|
pvc.metadata.labels = labels
|
||||||
|
|
||||||
|
|
||||||
def parse_pvc(pvc, viewers):
|
def parse_pvc(pvc, viewers, notebooks):
|
||||||
"""
|
"""
|
||||||
pvc: client.V1PersistentVolumeClaim
|
pvc: client.V1PersistentVolumeClaim
|
||||||
viewers: dict(Key:PVC Name, Value: Viewer's Status)
|
viewers: dict(Key:PVC Name, Value: Viewer's Status)
|
||||||
|
|
@ -47,7 +47,7 @@ def parse_pvc(pvc, viewers):
|
||||||
Process the PVC and format it as the UI expects it. If a Viewer is active
|
Process the PVC and format it as the UI expects it. If a Viewer is active
|
||||||
for that PVC, then include this information
|
for that PVC, then include this information
|
||||||
"""
|
"""
|
||||||
parsed_pvc = common_utils.parse_pvc(pvc)
|
parsed_pvc = common_utils.parse_pvc(pvc, notebooks)
|
||||||
parsed_pvc["viewer"] = viewers.get(pvc.metadata.name,
|
parsed_pvc["viewer"] = viewers.get(pvc.metadata.name,
|
||||||
status.STATUS_PHASE.UNINITIALIZED)
|
status.STATUS_PHASE.UNINITIALIZED)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"modes": ["ReadWriteOnce"],
|
"modes": ["ReadWriteOnce"],
|
||||||
"name": "a-pvc-phase-ready-viewer-ready",
|
"name": "a-pvc-phase-ready-viewer-ready",
|
||||||
"namespace": "kubeflow-user",
|
"namespace": "kubeflow-user",
|
||||||
|
"notebooks": [],
|
||||||
"status": {
|
"status": {
|
||||||
"message": "Bound",
|
"message": "Bound",
|
||||||
"phase": "ready",
|
"phase": "ready",
|
||||||
|
|
@ -21,6 +22,7 @@
|
||||||
"modes": ["ReadWriteOnce"],
|
"modes": ["ReadWriteOnce"],
|
||||||
"name": "a-pvc-phase-ready-viewer-uninitialized",
|
"name": "a-pvc-phase-ready-viewer-uninitialized",
|
||||||
"namespace": "kubeflow-user",
|
"namespace": "kubeflow-user",
|
||||||
|
"notebooks": [],
|
||||||
"status": {
|
"status": {
|
||||||
"message": "Bound",
|
"message": "Bound",
|
||||||
"phase": "ready",
|
"phase": "ready",
|
||||||
|
|
@ -35,6 +37,7 @@
|
||||||
"modes": ["ReadWriteOnce"],
|
"modes": ["ReadWriteOnce"],
|
||||||
"name": "a-pvc-phase-ready-viewer-waiting",
|
"name": "a-pvc-phase-ready-viewer-waiting",
|
||||||
"namespace": "kubeflow-user",
|
"namespace": "kubeflow-user",
|
||||||
|
"notebooks": [],
|
||||||
"status": {
|
"status": {
|
||||||
"message": "Bound",
|
"message": "Bound",
|
||||||
"phase": "ready",
|
"phase": "ready",
|
||||||
|
|
@ -49,6 +52,7 @@
|
||||||
"modes": ["ReadWriteOnce"],
|
"modes": ["ReadWriteOnce"],
|
||||||
"name": "a-pvc-phase-unvailable-viewer-unavailable",
|
"name": "a-pvc-phase-unvailable-viewer-unavailable",
|
||||||
"namespace": "kubeflow-user",
|
"namespace": "kubeflow-user",
|
||||||
|
"notebooks": [],
|
||||||
"status": {
|
"status": {
|
||||||
"message": "Pending: This volume will be bound when its first consumer is created. E.g., when you first browse its contents, or attach it to a notebook server",
|
"message": "Pending: This volume will be bound when its first consumer is created. E.g., when you first browse its contents, or attach it to a notebook server",
|
||||||
"phase": "unavailable",
|
"phase": "unavailable",
|
||||||
|
|
@ -63,6 +67,7 @@
|
||||||
"modes": ["ReadWriteOnce"],
|
"modes": ["ReadWriteOnce"],
|
||||||
"name": "a-pvc-phase-waiting-viewer-uninitialized",
|
"name": "a-pvc-phase-waiting-viewer-uninitialized",
|
||||||
"namespace": "kubeflow-user",
|
"namespace": "kubeflow-user",
|
||||||
|
"notebooks": ["test-notebook-1", "test-notebook-2"],
|
||||||
"status": {
|
"status": {
|
||||||
"message": "Bound",
|
"message": "Bound",
|
||||||
"phase": "waiting",
|
"phase": "waiting",
|
||||||
|
|
@ -77,6 +82,7 @@
|
||||||
"modes": ["ReadWriteOnce"],
|
"modes": ["ReadWriteOnce"],
|
||||||
"name": "a-pvc-phase-warning-viewer-ready",
|
"name": "a-pvc-phase-warning-viewer-ready",
|
||||||
"namespace": "kubeflow-user",
|
"namespace": "kubeflow-user",
|
||||||
|
"notebooks": [],
|
||||||
"status": {
|
"status": {
|
||||||
"message": "Bound",
|
"message": "Bound",
|
||||||
"phase": "warning",
|
"phase": "warning",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import { IndexRokComponent } from './pages/index/index-rok/index-rok.component';
|
||||||
|
|
||||||
import { HttpClientModule, HttpClient } from '@angular/common/http';
|
import { HttpClientModule, HttpClient } from '@angular/common/http';
|
||||||
import { VolumeDetailsPageModule } from './pages/volume-details-page/volume-details-page.module';
|
import { VolumeDetailsPageModule } from './pages/volume-details-page/volume-details-page.module';
|
||||||
|
import { ColumnsModule } from './pages/index/columns/columns.module';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
|
|
@ -45,6 +46,7 @@ import { VolumeDetailsPageModule } from './pages/volume-details-page/volume-deta
|
||||||
KubeflowModule,
|
KubeflowModule,
|
||||||
HttpClientModule,
|
HttpClientModule,
|
||||||
VolumeDetailsPageModule,
|
VolumeDetailsPageModule,
|
||||||
|
ColumnsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: ErrorStateMatcher, useClass: ImmediateErrorStateMatcher },
|
{ provide: ErrorStateMatcher, useClass: ImmediateErrorStateMatcher },
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
|
import { DeleteButtonComponent } from './delete-button/delete-button.component';
|
||||||
|
import { IconModule, KubeflowModule, UrlsModule } from 'kubeflow';
|
||||||
|
import { UsedByComponent } from './used-by/used-by.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [DeleteButtonComponent, UsedByComponent],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
MatTooltipModule,
|
||||||
|
IconModule,
|
||||||
|
KubeflowModule,
|
||||||
|
UrlsModule,
|
||||||
|
],
|
||||||
|
exports: [DeleteButtonComponent, UsedByComponent],
|
||||||
|
})
|
||||||
|
export class ColumnsModule {}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
<div>
|
||||||
|
<!--Ready Phase-->
|
||||||
|
<button
|
||||||
|
*ngIf="isPhaseReady()"
|
||||||
|
mat-icon-button
|
||||||
|
matTooltip="{{ action.tooltip }}"
|
||||||
|
matTooltipClass="custom-tooltip"
|
||||||
|
[color]="action.color"
|
||||||
|
(click)="emitClickedEvent()"
|
||||||
|
>
|
||||||
|
<lib-icon [icon]="action.iconReady"></lib-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
matTooltipClass="custom-tooltip"
|
||||||
|
[matTooltip]="'PVC is terminating at the moment.'"
|
||||||
|
matTooltipClass="custom-tooltip"
|
||||||
|
>
|
||||||
|
<button *ngIf="isPhaseTerminating()" mat-icon-button disabled>
|
||||||
|
<lib-icon [icon]="action.iconInit"></lib-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
matTooltipClass="custom-tooltip"
|
||||||
|
[matTooltip]="getDisabledTooltip(element)"
|
||||||
|
matTooltipClass="custom-tooltip"
|
||||||
|
>
|
||||||
|
<button *ngIf="isPhaseUnavailable()" mat-icon-button disabled>
|
||||||
|
<lib-icon [icon]="action.iconInit"></lib-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { DeleteButtonComponent } from './delete-button.component';
|
||||||
|
|
||||||
|
const mockElement = {
|
||||||
|
age: 'Mon, 19 Sep 2022 16:39:10 GMT',
|
||||||
|
capacity: '5Gi',
|
||||||
|
class: 'rok',
|
||||||
|
modes: ['ReadWriteOnce'],
|
||||||
|
name: 'a0-new-image-workspace-d8pc2',
|
||||||
|
namespace: 'kubeflow-user',
|
||||||
|
notebooks: ['a0-new-image'],
|
||||||
|
status: {
|
||||||
|
message: 'Bound',
|
||||||
|
phase: 'ready',
|
||||||
|
state: '',
|
||||||
|
},
|
||||||
|
viewer: 'uninitialized',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('DeleteButtonComponent', () => {
|
||||||
|
let component: DeleteButtonComponent;
|
||||||
|
let fixture: ComponentFixture<DeleteButtonComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [DeleteButtonComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(DeleteButtonComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
component.element = mockElement;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { ActionComponent, ActionIconValue, STATUS_TYPE } from 'kubeflow';
|
||||||
|
import { TableColumnComponent } from 'kubeflow/lib/resource-table/component-value/component-value.component';
|
||||||
|
@Component({
|
||||||
|
selector: 'app-delete-button',
|
||||||
|
templateUrl: './delete-button.component.html',
|
||||||
|
styleUrls: ['./delete-button.component.scss'],
|
||||||
|
})
|
||||||
|
export class DeleteButtonComponent
|
||||||
|
extends ActionComponent
|
||||||
|
implements TableColumnComponent, OnInit {
|
||||||
|
set element(data: any) {
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
get element() {
|
||||||
|
return this.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.action = new ActionIconValue({
|
||||||
|
name: 'delete',
|
||||||
|
tooltip: $localize`Delete Volume`,
|
||||||
|
color: 'warn',
|
||||||
|
field: 'deleteAction',
|
||||||
|
iconReady: 'material:delete',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isPhaseUnavailable(): boolean {
|
||||||
|
return this.status === STATUS_TYPE.UNAVAILABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPhaseTerminating(): boolean {
|
||||||
|
return this.status === STATUS_TYPE.TERMINATING;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisabledTooltip(element: any) {
|
||||||
|
let tooltip = `Cannot delete PVC because it is being used by the following notebooks:\n`;
|
||||||
|
|
||||||
|
for (const nb of element.notebooks) {
|
||||||
|
tooltip += `\n ${nb}`;
|
||||||
|
}
|
||||||
|
return tooltip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<div class="used-by-container truncate">
|
||||||
|
<span
|
||||||
|
[libPopover]="popoverCard"
|
||||||
|
[libPopoverPosition]="'before'"
|
||||||
|
[libPopoverHideDelay]="100"
|
||||||
|
[libPopoverShowDelay]="100"
|
||||||
|
>
|
||||||
|
<lib-urls
|
||||||
|
*ngFor="let nb of element.notebooks"
|
||||||
|
[urlList]="[getUrlItem(nb, element)]"
|
||||||
|
>
|
||||||
|
</lib-urls>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-template #popoverCard>
|
||||||
|
<div class="popoverCard">
|
||||||
|
<p>Notebooks</p>
|
||||||
|
<li *ngFor="let nb of element.notebooks">
|
||||||
|
<lib-urls [urlList]="[getUrlItem(nb, element)]"></lib-urls>
|
||||||
|
</li>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
.used-by-container {
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popoverCard {
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 4px 12px 4px 4px;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { UsedByComponent } from './used-by.component';
|
||||||
|
|
||||||
|
const mockElement = {
|
||||||
|
age: 'Mon, 19 Sep 2022 16:39:10 GMT',
|
||||||
|
capacity: '5Gi',
|
||||||
|
class: 'rok',
|
||||||
|
modes: ['ReadWriteOnce'],
|
||||||
|
name: 'a0-new-image-workspace-d8pc2',
|
||||||
|
namespace: 'kubeflow-user',
|
||||||
|
notebooks: ['a0-new-image'],
|
||||||
|
status: {
|
||||||
|
message: 'Bound',
|
||||||
|
phase: 'ready',
|
||||||
|
state: '',
|
||||||
|
},
|
||||||
|
viewer: 'uninitialized',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('UsedByComponent', () => {
|
||||||
|
let component: UsedByComponent;
|
||||||
|
let fixture: ComponentFixture<UsedByComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [UsedByComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(UsedByComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
component.element = mockElement;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { TableColumnComponent } from 'kubeflow/lib/resource-table/component-value/component-value.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-used-by',
|
||||||
|
templateUrl: './used-by.component.html',
|
||||||
|
styleUrls: ['./used-by.component.scss'],
|
||||||
|
})
|
||||||
|
export class UsedByComponent implements TableColumnComponent, OnInit {
|
||||||
|
public data: any;
|
||||||
|
|
||||||
|
set element(data: any) {
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
get element() {
|
||||||
|
return this.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
ngOnInit(): void {}
|
||||||
|
|
||||||
|
get pvcName() {
|
||||||
|
return this.element.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
getUrlItem(nb: string, element: any) {
|
||||||
|
return {
|
||||||
|
name: nb,
|
||||||
|
url: `/jupyter/notebook/details/${element.namespace}/${nb}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,8 +5,10 @@ import {
|
||||||
DateTimeValue,
|
DateTimeValue,
|
||||||
LinkValue,
|
LinkValue,
|
||||||
LinkType,
|
LinkType,
|
||||||
|
ComponentValue,
|
||||||
} from 'kubeflow';
|
} from 'kubeflow';
|
||||||
import { quantityToScalar } from '@kubernetes/client-node/dist/util';
|
import { quantityToScalar } from '@kubernetes/client-node/dist/util';
|
||||||
|
import { UsedByComponent } from './columns/used-by/used-by.component';
|
||||||
|
|
||||||
export const tableConfig: TableConfig = {
|
export const tableConfig: TableConfig = {
|
||||||
columns: [
|
columns: [
|
||||||
|
|
@ -62,6 +64,17 @@ export const tableConfig: TableConfig = {
|
||||||
value: new PropertyValue({ field: 'class', truncate: true }),
|
value: new PropertyValue({ field: 'class', truncate: true }),
|
||||||
sort: true,
|
sort: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
matHeaderCellDef: $localize`Used by`,
|
||||||
|
matColumnDef: 'usedBy',
|
||||||
|
style: { 'max-width': '60px' },
|
||||||
|
value: new ComponentValue({
|
||||||
|
component: UsedByComponent,
|
||||||
|
}),
|
||||||
|
sort: true,
|
||||||
|
sortingPreprocessorFn: element => element.notebooks,
|
||||||
|
filteringPreprocessorFn: element => element.notebooks,
|
||||||
|
},
|
||||||
|
|
||||||
// the apps should import the actions they want
|
// the apps should import the actions they want
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,19 @@
|
||||||
import {
|
import { TableColumn, TableConfig, ComponentValue } from 'kubeflow';
|
||||||
ActionListValue,
|
|
||||||
ActionIconValue,
|
|
||||||
TableColumn,
|
|
||||||
TableConfig,
|
|
||||||
} from 'kubeflow';
|
|
||||||
import { tableConfig } from '../config';
|
import { tableConfig } from '../config';
|
||||||
|
import { DeleteButtonComponent } from '../columns/delete-button/delete-button.component';
|
||||||
|
|
||||||
const actionsCol: TableColumn = {
|
const customDeleteCol: TableColumn = {
|
||||||
matHeaderCellDef: '',
|
matHeaderCellDef: '',
|
||||||
matColumnDef: 'actions',
|
matColumnDef: 'customDelete',
|
||||||
value: new ActionListValue([
|
style: { width: '40px' },
|
||||||
new ActionIconValue({
|
value: new ComponentValue({
|
||||||
name: 'delete',
|
component: DeleteButtonComponent,
|
||||||
tooltip: $localize`Delete Volume`,
|
}),
|
||||||
color: 'warn',
|
|
||||||
field: 'deleteAction',
|
|
||||||
iconReady: 'material:delete',
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultConfig: TableConfig = {
|
export const defaultConfig: TableConfig = {
|
||||||
title: tableConfig.title,
|
title: tableConfig.title,
|
||||||
dynamicNamespaceColumn: true,
|
dynamicNamespaceColumn: true,
|
||||||
newButtonText: tableConfig.newButtonText,
|
newButtonText: tableConfig.newButtonText,
|
||||||
columns: tableConfig.columns.concat(actionsCol),
|
columns: tableConfig.columns.concat(customDeleteCol),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,10 @@ export class IndexDefaultComponent implements OnInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
public parseDeletionActionStatus(pvc: PVCProcessedObject) {
|
public parseDeletionActionStatus(pvc: PVCProcessedObject) {
|
||||||
|
if (pvc.notebooks.length) {
|
||||||
|
return STATUS_TYPE.UNAVAILABLE;
|
||||||
|
}
|
||||||
|
|
||||||
if (pvc.status.phase !== STATUS_TYPE.TERMINATING) {
|
if (pvc.status.phase !== STATUS_TYPE.TERMINATING) {
|
||||||
return STATUS_TYPE.READY;
|
return STATUS_TYPE.READY;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,15 @@ import {
|
||||||
ActionIconValue,
|
ActionIconValue,
|
||||||
TableColumn,
|
TableColumn,
|
||||||
TableConfig,
|
TableConfig,
|
||||||
|
ComponentValue,
|
||||||
} from 'kubeflow';
|
} from 'kubeflow';
|
||||||
import { tableConfig } from '../config';
|
import { tableConfig } from '../config';
|
||||||
|
import { DeleteButtonComponent } from '../columns/delete-button/delete-button.component';
|
||||||
|
|
||||||
const actionsCol: TableColumn = {
|
const browseCol: TableColumn = {
|
||||||
matHeaderCellDef: '',
|
matHeaderCellDef: '',
|
||||||
matColumnDef: 'actions',
|
matColumnDef: 'browse',
|
||||||
|
style: { 'padding-right': '0' },
|
||||||
value: new ActionListValue([
|
value: new ActionListValue([
|
||||||
new ActionIconValue({
|
new ActionIconValue({
|
||||||
name: 'edit',
|
name: 'edit',
|
||||||
|
|
@ -18,19 +21,21 @@ const actionsCol: TableColumn = {
|
||||||
iconInit: 'material:folder',
|
iconInit: 'material:folder',
|
||||||
iconReady: 'custom:folderSearch',
|
iconReady: 'custom:folderSearch',
|
||||||
}),
|
}),
|
||||||
new ActionIconValue({
|
|
||||||
name: 'delete',
|
|
||||||
tooltip: 'Delete Volume',
|
|
||||||
color: 'warn',
|
|
||||||
field: 'deleteAction',
|
|
||||||
iconReady: 'material:delete',
|
|
||||||
}),
|
|
||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const customDeleteCol: TableColumn = {
|
||||||
|
matHeaderCellDef: ' ',
|
||||||
|
matColumnDef: 'customDelete',
|
||||||
|
style: { width: '40px', 'padding-left': '0' },
|
||||||
|
value: new ComponentValue({
|
||||||
|
component: DeleteButtonComponent,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
export const rokConfig: TableConfig = {
|
export const rokConfig: TableConfig = {
|
||||||
title: tableConfig.title,
|
title: tableConfig.title,
|
||||||
dynamicNamespaceColumn: true,
|
dynamicNamespaceColumn: true,
|
||||||
newButtonText: tableConfig.newButtonText,
|
newButtonText: tableConfig.newButtonText,
|
||||||
columns: tableConfig.columns.concat([actionsCol]),
|
columns: tableConfig.columns.concat([browseCol, customDeleteCol]),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export interface PVCResponseObject {
|
||||||
name: string;
|
name: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
status: Status;
|
status: Status;
|
||||||
|
notebooks: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PVCProcessedObject extends PVCResponseObject {
|
export interface PVCProcessedObject extends PVCResponseObject {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue