diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 1ddc2b80..d5a30b5b 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -29,7 +29,7 @@ import { NamespaceSelectComponent } from "./main-table/namespace-select/namespac import { ResourceTableComponent } from "./main-table/resource-table/resource-table.component"; import { SnackBarComponent } from "./services/snack-bar/snack-bar.component"; import { ResourceFormComponent } from "./resource-form/resource-form.component"; -import { ConfirmDialogComponent } from "./main-table/resource-table/confirm-dialog/confirm-dialog.component"; +import { ConfirmDialogComponent } from "./main-table/confirm-dialog/confirm-dialog.component"; import { VolumeComponent } from "./resource-form/volume/volume.component"; import { FormNameComponent } from "./resource-form/form-name/form-name.component"; import { FormImageComponent } from "./resource-form/form-image/form-image.component"; @@ -48,6 +48,7 @@ import { RokFormDataVolumesComponent } from "./uis/rok/rok-resource-form/rok-for import { RokErrorMsgComponent } from "./uis/rok/rok-error-msg/rok-error-msg.component"; import { FormConfigurationsComponent } from "./resource-form/form-configurations/form-configurations.component"; import { FormGpusComponent } from "./resource-form/form-gpus/form-gpus.component"; +import { VolumeTableComponent } from "./main-table/volumes-table/volume-table.component"; @NgModule({ @@ -77,6 +78,7 @@ import { FormGpusComponent } from "./resource-form/form-gpus/form-gpus.component RokErrorMsgComponent, FormConfigurationsComponent, FormGpusComponent, + VolumeTableComponent, ], imports: [ BrowserModule, diff --git a/frontend/src/app/main-table/resource-table/confirm-dialog/confirm-dialog.component.html b/frontend/src/app/main-table/confirm-dialog/confirm-dialog.component.html similarity index 100% rename from frontend/src/app/main-table/resource-table/confirm-dialog/confirm-dialog.component.html rename to frontend/src/app/main-table/confirm-dialog/confirm-dialog.component.html diff --git a/frontend/src/app/main-table/resource-table/confirm-dialog/confirm-dialog.component.scss b/frontend/src/app/main-table/confirm-dialog/confirm-dialog.component.scss similarity index 100% rename from frontend/src/app/main-table/resource-table/confirm-dialog/confirm-dialog.component.scss rename to frontend/src/app/main-table/confirm-dialog/confirm-dialog.component.scss diff --git a/frontend/src/app/main-table/resource-table/confirm-dialog/confirm-dialog.component.spec.ts b/frontend/src/app/main-table/confirm-dialog/confirm-dialog.component.spec.ts similarity index 100% rename from frontend/src/app/main-table/resource-table/confirm-dialog/confirm-dialog.component.spec.ts rename to frontend/src/app/main-table/confirm-dialog/confirm-dialog.component.spec.ts diff --git a/frontend/src/app/main-table/resource-table/confirm-dialog/confirm-dialog.component.ts b/frontend/src/app/main-table/confirm-dialog/confirm-dialog.component.ts similarity index 100% rename from frontend/src/app/main-table/resource-table/confirm-dialog/confirm-dialog.component.ts rename to frontend/src/app/main-table/confirm-dialog/confirm-dialog.component.ts diff --git a/frontend/src/app/main-table/main-table.component.html b/frontend/src/app/main-table/main-table.component.html index 52f76dba..670a3d48 100644 --- a/frontend/src/app/main-table/main-table.component.html +++ b/frontend/src/app/main-table/main-table.component.html @@ -8,6 +8,21 @@
- +
+ + +
+
+ + +
+
+ diff --git a/frontend/src/app/main-table/main-table.component.scss b/frontend/src/app/main-table/main-table.component.scss index c4dcc23d..1d7cda90 100644 --- a/frontend/src/app/main-table/main-table.component.scss +++ b/frontend/src/app/main-table/main-table.component.scss @@ -9,3 +9,98 @@ .spacing { padding-top: 1.5rem; } + +.card { + width: 900px; + padding: 0px; + border-radius: 5px; + background: white; +} + +table { + width: 100%; +} + +.header { + display: flex; + align-items: center; + padding: 0px 16px 0px 16px; + height: 64px; +} + +.header mat-icon { + margin: 3px 10px 0 0; +} + +.header p { + font-weight: 400; + font-size: 20px; +} + +.cdk-column-actions { + text-align: center; +} + +.mat-icon { + line-height: 0.85; +} + +td.mat-cell:last-of-type, +td.mat-footer-cell:last-of-type, +th.mat-header-cell:last-of-type { + padding-right: 0px; +} + +.inline { + display: inline-block; +} + +// Status Icons +.running { + color: green; +} + +.warning { + color: orange; +} + +.error { + color: red; +} + +.status { + display: inline-flex; + vertical-align: middle; +} + +.delete { + color: red; +} + +// Flex +.parent { + display: flex; +} + +.spacer { + flex-grow: 1; +} + +th, +td { + overflow: hidden; + text-overflow: ellipsis; +} + +td.mat-column-image, +td.mat-column-name { + max-width: 200px; +} + +td.mat-column-cpu { + width: 40px; +} + +td.mat-column-actions{ + text-align: center; +} diff --git a/frontend/src/app/main-table/main-table.component.ts b/frontend/src/app/main-table/main-table.component.ts index a681d481..608c8f1d 100644 --- a/frontend/src/app/main-table/main-table.component.ts +++ b/frontend/src/app/main-table/main-table.component.ts @@ -1,5 +1,14 @@ -import { Component, OnInit } from "@angular/core"; -import { NamespaceService } from "../services/namespace.service"; +import {Component, OnInit} from "@angular/core"; +import {NamespaceService} from "../services/namespace.service"; +import {KubernetesService} from "src/app/services/kubernetes.service"; + +import {Subscription} from "rxjs"; +import {isEqual} from "lodash"; +import {first} from "rxjs/operators"; + +import {ExponentialBackoff} from "src/app/utils/polling"; +import {Volume, Resource} from "../utils/types"; +import {PvcWithStatus} from "./volumes-table/volume-table.component" @Component({ selector: "app-main-table", @@ -7,10 +16,72 @@ import { NamespaceService } from "../services/namespace.service"; styleUrls: ["./main-table.component.scss"] }) export class MainTableComponent implements OnInit { + currNamespace = ""; namespaces = []; - currNamespace: string; + resources = []; + + pvcs: Volume[] = []; + pvcProperties: PvcWithStatus[] = []; + + subscriptions = new Subscription(); + poller: ExponentialBackoff; + + constructor( + public ns: NamespaceService, + private k8s: KubernetesService, + ) {} + + ngOnInit() { + this.poller = new ExponentialBackoff({interval: 2000, retries: 3}); + const resourcesSub = this.poller.start().subscribe(() => { + if (!this.currNamespace) { + return; + } + + Promise.all([ + this.k8s.getResource(this.currNamespace).toPromise(), + this.k8s.getVolumes(this.currNamespace).toPromise() + ]).then(([notebooks, volumes]) => { + if (!isEqual(notebooks, this.resources) || !isEqual(volumes, this.pvcs)) { + this.poller.reset(); + } + this.resources = notebooks; + this.pvcs = volumes; + let mounts = Object.fromEntries( + notebooks.flatMap(nb => nb.volumes.map(v => [v, nb])) + ); + this.pvcProperties = volumes.map(v => ({ + pvc: v, + mountedBy: mounts[v.name]?.name + })); + }); + }); + + // Keep track of the selected namespace + const namespaceSub = this.ns.getSelectedNamespace().subscribe(namespace => { + this.currNamespace = namespace; + this.poller.reset(); + }); + + this.subscriptions.add(resourcesSub); + this.subscriptions.add(namespaceSub); + } - constructor(public ns: NamespaceService) {} + deleteResource(rsrc: Resource): void { + this.k8s + .deleteResource(rsrc.namespace, rsrc.name) + .pipe(first()) + .subscribe(r => { + this.poller.reset(); + }); + } - ngOnInit() {} + deletePvc(p: PvcWithStatus): void { + this.k8s + .deletePersistentVolumeClaim(p.pvc.namespace, p.pvc.name) + .pipe(first()) + .subscribe(_ => { + this.poller.reset(); + }); + } } diff --git a/frontend/src/app/main-table/resource-table/resource-table.component.html b/frontend/src/app/main-table/resource-table/resource-table.component.html index 298fd87d..46d55784 100644 --- a/frontend/src/app/main-table/resource-table/resource-table.component.html +++ b/frontend/src/app/main-table/resource-table/resource-table.component.html @@ -1,5 +1,6 @@
+ computer

Notebook Servers

@@ -13,7 +14,7 @@ - +
diff --git a/frontend/src/app/main-table/resource-table/resource-table.component.scss b/frontend/src/app/main-table/resource-table/resource-table.component.scss index b5b32094..e69de29b 100644 --- a/frontend/src/app/main-table/resource-table/resource-table.component.scss +++ b/frontend/src/app/main-table/resource-table/resource-table.component.scss @@ -1,86 +0,0 @@ -.card { - width: 900px; - padding: 0px; - border-radius: 5px; - background: white; -} - -table { - width: 100%; -} - -.header { - display: flex; - align-items: center; - padding: 0px 16px 0px 16px; - height: 64px; -} - -.header p { - font-weight: 400; - font-size: 20px; -} - -.cdk-column-actions { - text-align: center; -} - -.mat-icon { - line-height: 0.85; -} - -td.mat-cell:last-of-type, -td.mat-footer-cell:last-of-type, -th.mat-header-cell:last-of-type { - padding-right: 0px; -} - -.inline { - display: inline-block; -} - -// Status Icons -.running { - color: green; -} - -.warning { - color: orange; -} - -.error { - color: red; -} - -.status { - display: inline-flex; - vertical-align: middle; -} - -.delete { - color: red; -} - -// Flex -.parent { - display: flex; -} - -.spacer { - flex-grow: 1; -} - -th, -td { - overflow: hidden; - text-overflow: ellipsis; -} - -td.mat-column-image, -td.mat-column-name { - max-width: 200px; -} - -td.mat-column-cpu { - width: 40px; -} diff --git a/frontend/src/app/main-table/resource-table/resource-table.component.ts b/frontend/src/app/main-table/resource-table/resource-table.component.ts index 899d60f4..ed6de115 100644 --- a/frontend/src/app/main-table/resource-table/resource-table.component.ts +++ b/frontend/src/app/main-table/resource-table/resource-table.component.ts @@ -1,33 +1,19 @@ -import { Component, OnInit, ViewChild } from "@angular/core"; -import { MatSort } from "@angular/material/sort"; +import { Component, OnInit, ViewChild, Input, Output, EventEmitter } from "@angular/core"; import { MatTableDataSource } from "@angular/material/table"; -import { MatDialog } from "@angular/material/dialog"; -import { Subscription } from "rxjs"; -import { first } from "rxjs/operators"; -import { isEqual } from "lodash"; - -import { NamespaceService } from "src/app/services/namespace.service"; -import { KubernetesService } from "src/app/services/kubernetes.service"; import { Resource } from "src/app/utils/types"; -import { ExponentialBackoff } from "src/app/utils/polling"; -import { ConfirmDialogComponent } from "./confirm-dialog/confirm-dialog.component"; +import {MatDialog} from "@angular/material/dialog"; +import {ConfirmDialogComponent} from "../confirm-dialog/confirm-dialog.component"; +import {first} from "rxjs/operators"; @Component({ selector: "app-resource-table", templateUrl: "./resource-table.component.html", - styleUrls: ["./resource-table.component.scss"] + styleUrls: ["./resource-table.component.scss", "../main-table.component.scss"] }) export class ResourceTableComponent implements OnInit { - @ViewChild(MatSort) sort: MatSort; - - // Logic data - resources = []; - currNamespace = ""; - - subscriptions = new Subscription(); - poller: ExponentialBackoff; + @Input() notebooks: Resource[]; + @Output() deleteNotebookEvent = new EventEmitter(); - // Table data displayedColumns: string[] = [ "status", "name", @@ -40,57 +26,19 @@ export class ResourceTableComponent implements OnInit { ]; dataSource = new MatTableDataSource(); - showNameFilter = false; - constructor( - private namespaceService: NamespaceService, - private k8s: KubernetesService, private dialog: MatDialog ) {} - ngOnInit() { - this.dataSource.sort = this.sort; - - // Create the exponential backoff poller - this.poller = new ExponentialBackoff({ interval: 2000, retries: 3 }); - const resourcesSub = this.poller.start().subscribe(() => { - // NOTE: We are using both the 'trackBy' feature in the Table for performance - // and also detecting with lodash if the new data is different from the old - // one. This is because, if the data changes we want to reset the poller - if (!this.currNamespace) { - return; - } + ngOnInit() { } - this.k8s.getResource(this.currNamespace).subscribe(resources => { - if (!isEqual(this.resources, resources)) { - this.resources = resources; - this.dataSource.data = this.resources; - this.poller.reset(); - } - }); - }); - - // Keep track of the selected namespace - const namespaceSub = this.namespaceService - .getSelectedNamespace() - .subscribe(namespace => { - this.currNamespace = namespace; - this.poller.reset(); - }); - - this.subscriptions.add(resourcesSub); - this.subscriptions.add(namespaceSub); - } - - ngOnDestroy() { - this.subscriptions.unsubscribe(); - } + ngOnDestroy() { } // Resource (Notebook) Actions connectResource(rsrc: Resource): void { window.open(`/notebook/${rsrc.namespace}/${rsrc.name}/`); } - + deleteResource(rsrc: Resource): void { const dialogRef = this.dialog.open(ConfirmDialogComponent, { width: "fit-content", @@ -103,30 +51,15 @@ export class ResourceTableComponent implements OnInit { no: "cancel" } }); - dialogRef .afterClosed() .pipe(first()) .subscribe(result => { - if (!result || result !== "delete") { + if (result !== "delete") { return; } - - this.k8s - .deleteResource(rsrc.namespace, rsrc.name) - .pipe(first()) - .subscribe(r => { - this.poller.reset(); - }); + this.deleteNotebookEvent.emit(rsrc); }); } - // Misc - trackByFn(index: number, r: Resource) { - return `${r.name}/${r.namespace}/${r.age}/${r.status}/${r.reason}`; - } - - toggleFilter() { - this.showNameFilter = !this.showNameFilter; - } } diff --git a/frontend/src/app/main-table/volumes-table/volume-table.component.html b/frontend/src/app/main-table/volumes-table/volume-table.component.html new file mode 100644 index 00000000..180e47dc --- /dev/null +++ b/frontend/src/app/main-table/volumes-table/volume-table.component.html @@ -0,0 +1,76 @@ +
+
+ storage +

Notebook Volumes

+
+ +
Status
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Status + + link + + + + link_off + + + + + + Name{{ elem.pvc.name }}Size + {{ elem.pvc.size }} + Used By + {{ elem.mountedBy || '(None)' }} + + +
+
diff --git a/frontend/src/app/main-table/volumes-table/volume-table.component.scss b/frontend/src/app/main-table/volumes-table/volume-table.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/app/main-table/volumes-table/volume-table.component.spec.ts b/frontend/src/app/main-table/volumes-table/volume-table.component.spec.ts new file mode 100644 index 00000000..3ea890d0 --- /dev/null +++ b/frontend/src/app/main-table/volumes-table/volume-table.component.spec.ts @@ -0,0 +1,24 @@ +import { async, ComponentFixture, TestBed } from "@angular/core/testing"; + +import { VolumeTableComponent } from "./volume-table.component"; + +describe("VolumeTableComponent", () => { + let component: VolumeTableComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [VolumeTableComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(VolumeTableComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/main-table/volumes-table/volume-table.component.ts b/frontend/src/app/main-table/volumes-table/volume-table.component.ts new file mode 100644 index 00000000..8a4a330a --- /dev/null +++ b/frontend/src/app/main-table/volumes-table/volume-table.component.ts @@ -0,0 +1,87 @@ +import {Component, Input, Output, EventEmitter, OnChanges, SimpleChanges} from "@angular/core"; +import {MatTableDataSource} from "@angular/material/table"; +import {Volume} from "../../utils/types"; +import {MatDialog} from "@angular/material/dialog"; +import {ConfirmDialogComponent} from "../confirm-dialog/confirm-dialog.component"; +import {first} from "rxjs/operators"; + +export type PvcWithStatus = { + pvc: Volume; + mountedBy: string | null; +} + +enum PvcStatus { + DELETING, + MOUNTED, + UNMOUNTED +} + +@Component({ + selector: "app-volume-table", + templateUrl: "./volume-table.component.html", + styleUrls: ["./volume-table.component.scss", "../main-table.component.scss"] +}) +export class VolumeTableComponent implements OnChanges { + @Input() pvcProperties: PvcWithStatus[]; + @Output() deletePvcEvent = new EventEmitter(); + + PvcStatus = PvcStatus; + + // Table data + displayedColumns: string[] = [ + "status", + "name", + "size", + "mountedBy", + "actions" + ]; + dataSource = new MatTableDataSource(); + + deletionStatus: Set = new Set(); + + constructor(private dialog: MatDialog) {} + + ngOnChanges(changes: SimpleChanges): void { + if (changes.pvcProperties) { + const pvcNames = (changes.pvcProperties + .currentValue as PvcWithStatus[]).map(p => p.pvc.name); + this.deletionStatus.forEach(name => { + if (!pvcNames.includes(name)) { + this.deletionStatus.delete(name); + } + }); + } + } + + deletePvc(pvc: PvcWithStatus): void { + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: "fit-content", + data: { + title: "You are about to delete the volume: " + pvc.pvc.name, + message: + "Are you sure you want to delete this volume? " + + "This action can't be undone.", + yes: "delete", + no: "cancel" + } + }); + + dialogRef + .afterClosed() + .pipe(first()) + .subscribe(result => { + if (result !== "delete") { + return; + } + this.deletionStatus.add(pvc.pvc.name); + this.deletePvcEvent.emit(pvc); + }); + } + + pvcStatus(pvc: PvcWithStatus): PvcStatus { + if (this.deletionStatus.has(pvc.pvc.name)) { + return PvcStatus.DELETING; + } + return pvc.mountedBy ? PvcStatus.MOUNTED : PvcStatus.UNMOUNTED; + } +} diff --git a/frontend/src/app/services/kubernetes.service.ts b/frontend/src/app/services/kubernetes.service.ts index 16ec7247..7cf351a1 100644 --- a/frontend/src/app/services/kubernetes.service.ts +++ b/frontend/src/app/services/kubernetes.service.ts @@ -109,11 +109,26 @@ export class KubernetesService { ); } - // Delete functions - deleteResource(ns: string, nm: string): Observable { + //Delete functions + deleteResource(ns: string, name: string): Observable { const url = environment.apiUrl + - `/api/namespaces/${ns}/${environment.resource}/${nm}`; + `/api/namespaces/${ns}/${environment.resource}/${name}`; + + return this.http.delete(url).pipe( + tap(data => this.handleBackendError(data)), + catchError(error => this.handleError(error)), + map(_ => { + return "deleted"; + }) + ); + } + + // Delete pvc + deletePersistentVolumeClaim(ns: string, name: string): Observable { + const url = + environment.apiUrl + + `/api/namespaces/${ns}/pvcs/${name}`; return this.http.delete(url).pipe( tap(data => this.handleBackendError(data)), diff --git a/frontend/src/app/utils/types.ts b/frontend/src/app/utils/types.ts index d1bb1ecf..d4c83386 100644 --- a/frontend/src/app/utils/types.ts +++ b/frontend/src/app/utils/types.ts @@ -1,7 +1,7 @@ export interface Volume { name: string; size: string; - namepsace?: string; + namespace?: string; class?: string; mode: string; type?: string; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index b271fd9f..2bb72cbe 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -15,7 +15,7 @@ "node_modules/@types" ], "lib": [ - "es2018", + "es2020", "dom" ] } diff --git a/main.go b/main.go index 498436b0..a672d84d 100644 --- a/main.go +++ b/main.go @@ -177,6 +177,17 @@ func main() { }, }, }, s.GetPersistentVolumeClaims)).Methods("GET") + + router.HandleFunc("/api/namespaces/{namespace}/pvcs/{pvc}", s.checkAccess(authorizationv1.SubjectAccessReview{ + Spec: authorizationv1.SubjectAccessReviewSpec{ + ResourceAttributes: &authorizationv1.ResourceAttributes{ + Group: corev1.SchemeGroupVersion.Group, + Verb: "delete", + Resource: "persistentvolumeclaims", + Version: corev1.SchemeGroupVersion.Version, + }, + }, + }, s.DeletePvc)).Methods("DELETE") router.HandleFunc("/api/namespaces/{namespace}/poddefaults", s.checkAccess(authorizationv1.SubjectAccessReview{ Spec: authorizationv1.SubjectAccessReviewSpec{ ResourceAttributes: &authorizationv1.ResourceAttributes{ diff --git a/persistentvolumeclaims.go b/persistentvolumeclaims.go index 73601507..cfba08f5 100644 --- a/persistentvolumeclaims.go +++ b/persistentvolumeclaims.go @@ -9,6 +9,7 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/labels" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) type pvcresponse struct { @@ -60,3 +61,26 @@ func (s *server) GetPersistentVolumeClaims(w http.ResponseWriter, r *http.Reques s.respond(w, r, resp) } + +//TODO: Delete pvc +func (s *server) DeletePvc(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + namespace := vars["namespace"] + pvc := vars["pvc"] + + log.Printf("deleting pvc %q for %q", pvc, namespace) + + propagation := v1.DeletePropagationForeground + err := s.clientsets.kubernetes.CoreV1().PersistentVolumeClaims(namespace).Delete(r.Context(), pvc, v1.DeleteOptions{ + PropagationPolicy: &propagation, + }) + if err != nil { + s.error(w, r, err) + return + } + + s.respond(w, r, APIResponse{ + Success: true, + }) +} +